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:
hurkicorgi 2026-04-13 06:52:05 -06:00
parent 3a69dc9243
commit 1ac5513d2c
7 changed files with 151 additions and 18 deletions

View file

@ -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"}