Role-based access: admin vs superadmin
- Credentials provider now resolves two distinct accounts from env:
SUPERADMIN_USERNAME / SUPERADMIN_PASSWORD → role "superadmin"
ADMIN_USERNAME / ADMIN_PASSWORD → role "admin"
The role is carried through the JWT and Session callbacks so the UI
and API can gate on it. Types extended via types/next-auth.d.ts.
- lib/auth.ts exports requireRole(minRole) and sessionRole(session).
- /api/players POST rejects "op" and "deop" with 403 unless the caller
is superadmin. All other player actions (whitelist add/remove, ban,
pardon) remain available to both roles.
- lib/use-role.ts (client hook) exposes role / isSuperadmin / authed
for UI gating without duplicating session typing.
- PlayerManager: Add-OP button and per-row Deop action are hidden or
disabled for non-superadmin; when a regular admin is viewing the
Ops tab, a banner explains the read-only state.
- PlayerDrawer: Make Op / Deop button disabled with tooltip for
non-superadmin; whitelist, ban, pardon unchanged.
- Navbar: subtle role pill next to the user name ("Super" for
superadmin amber-tinted, "Admin" for admin).
- Migration note: the existing ADMIN_* credentials now log in as the
restricted admin role. Set SUPERADMIN_USERNAME + SUPERADMIN_PASSWORD
in .env.local to retain operator-management ability. A placeholder
superadmin account was generated in .env.local; the password is in
the commit terminal output only, not the repo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a69dc9243
commit
1ac5513d2c
7 changed files with 151 additions and 18 deletions
|
|
@ -7,6 +7,7 @@ import { ThemeToggle } from "@/components/ThemeToggle";
|
|||
|
||||
export function Navbar() {
|
||||
const { data: session } = useSession();
|
||||
const role = (session?.user as { role?: "admin" | "superadmin" } | undefined)?.role;
|
||||
|
||||
return (
|
||||
<header className="border-b border-border bg-card">
|
||||
|
|
@ -24,6 +25,18 @@ export function Navbar() {
|
|||
<ThemeToggle />
|
||||
{session ? (
|
||||
<>
|
||||
{role && (
|
||||
<span
|
||||
className={`hidden sm:inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide border ${
|
||||
role === "superadmin"
|
||||
? "border-amber-500/40 bg-amber-500/10 text-amber-300"
|
||||
: "border-border bg-muted text-muted-foreground"
|
||||
}`}
|
||||
title={`Signed in as ${session.user?.name} (${role})`}
|
||||
>
|
||||
{role === "superadmin" ? "Super" : "Admin"}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" render={<Link href="/admin" />}>
|
||||
Admin
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { PlayerAvatar } from "@/components/PlayerAvatar";
|
|||
import { onAppEvent } from "@/lib/events";
|
||||
import { formatHours, timeAgo } from "@/lib/time";
|
||||
import type { PlayerStats } from "@/lib/player-stats";
|
||||
import { useRole } from "@/lib/use-role";
|
||||
|
||||
type PlayerData = {
|
||||
ops: { name: string; uuid: string; level: number }[];
|
||||
|
|
@ -23,6 +24,7 @@ type MetricEntry = { ts: string; players?: string[] };
|
|||
|
||||
export function PlayerDrawer() {
|
||||
const { data: session } = useSession();
|
||||
const { isSuperadmin } = useRole();
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [banReason, setBanReason] = useState("");
|
||||
|
|
@ -200,7 +202,8 @@ export function PlayerDrawer() {
|
|||
onClick={() =>
|
||||
action.mutate({ action: isOp ? "deop" : "op" })
|
||||
}
|
||||
disabled={action.isPending}
|
||||
disabled={action.isPending || !isSuperadmin}
|
||||
title={!isSuperadmin ? "Super admins only" : undefined}
|
||||
className="text-sm"
|
||||
>
|
||||
{isOp ? "Deop" : "Make Op"}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "@/components/ui/card";
|
||||
import { PlayerAvatar } from "@/components/PlayerAvatar";
|
||||
import { Leaderboard } from "@/components/Leaderboard";
|
||||
import { useRole } from "@/lib/use-role";
|
||||
|
||||
type PlayerData = {
|
||||
ops: { name: string; uuid: string; level: number }[];
|
||||
|
|
@ -27,6 +28,7 @@ type Tab = "ops" | "whitelist" | "banned";
|
|||
|
||||
export function PlayerManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const { isSuperadmin } = useRole();
|
||||
const [tab, setTab] = useState<Tab>("ops");
|
||||
const [playerName, setPlayerName] = useState("");
|
||||
const [banReason, setBanReason] = useState("");
|
||||
|
|
@ -196,7 +198,12 @@ export function PlayerManager() {
|
|||
else if (tab === "whitelist") handleAction("whitelist add");
|
||||
else if (tab === "banned") handleAction("ban");
|
||||
}}
|
||||
disabled={!nameValid || action.isPending}
|
||||
disabled={
|
||||
!nameValid ||
|
||||
action.isPending ||
|
||||
(tab === "ops" && !isSuperadmin)
|
||||
}
|
||||
title={tab === "ops" && !isSuperadmin ? "Super admins only" : undefined}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{tab === "ops" ? "Add OP" : tab === "whitelist" ? "Add" : "Ban"}
|
||||
|
|
@ -206,6 +213,11 @@ export function PlayerManager() {
|
|||
<Separator />
|
||||
|
||||
{/* Player lists */}
|
||||
{tab === "ops" && !isSuperadmin && (
|
||||
<p className="text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
|
||||
Read-only — operator management requires super-admin.
|
||||
</p>
|
||||
)}
|
||||
{tab === "ops" && (
|
||||
<ul className="space-y-1">
|
||||
{data.ops.length === 0 && (
|
||||
|
|
@ -223,15 +235,17 @@ export function PlayerManager() {
|
|||
Lv{p.level}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleAction("deop", p.name)}
|
||||
disabled={action.isPending}
|
||||
className="text-xs h-9 shrink-0 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||
>
|
||||
Deop
|
||||
</Button>
|
||||
{isSuperadmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleAction("deop", p.name)}
|
||||
disabled={action.isPending}
|
||||
className="text-xs h-9 shrink-0 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||
>
|
||||
Deop
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue