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

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { readFileSync } from "fs";
import { auth } from "@/lib/auth";
import { auth, sessionRole } from "@/lib/auth";
import { sendCommand } from "@/lib/rcon";
import { memo, invalidate } from "@/lib/cache";
@ -76,6 +76,13 @@ export async function POST(req: NextRequest) {
);
}
if ((action === "op" || action === "deop") && sessionRole(session) !== "superadmin") {
return NextResponse.json(
{ error: "Only super admins can manage operators" },
{ status: 403 }
);
}
let command = `${action} ${player}`;
if (action === "ban" && reason) {
command += ` ${reason}`;

View file

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

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

View file

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

View file

@ -1,6 +1,31 @@
import NextAuth from "next-auth";
import NextAuth, { type Session } from "next-auth";
import Credentials from "next-auth/providers/credentials";
export type Role = "admin" | "superadmin";
type AccountEntry = { username: string; password: string; role: Role; id: string };
function loadAccounts(): AccountEntry[] {
const accounts: AccountEntry[] = [];
if (process.env.SUPERADMIN_USERNAME && process.env.SUPERADMIN_PASSWORD) {
accounts.push({
id: "superadmin",
username: process.env.SUPERADMIN_USERNAME,
password: process.env.SUPERADMIN_PASSWORD,
role: "superadmin",
});
}
if (process.env.ADMIN_USERNAME && process.env.ADMIN_PASSWORD) {
accounts.push({
id: "admin",
username: process.env.ADMIN_USERNAME,
password: process.env.ADMIN_PASSWORD,
role: "admin",
});
}
return accounts;
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
@ -10,16 +35,39 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (
credentials?.username === process.env.ADMIN_USERNAME &&
credentials?.password === process.env.ADMIN_PASSWORD
) {
return { id: "1", name: "Admin" };
const username = credentials?.username;
const password = credentials?.password;
if (typeof username !== "string" || typeof password !== "string") {
return null;
}
for (const acc of loadAccounts()) {
if (acc.username === username && acc.password === password) {
return {
id: acc.id,
name: acc.username,
role: acc.role,
};
}
}
return null;
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user && "role" in user) {
token.role = (user as { role?: Role }).role;
}
return token;
},
async session({ session, token }) {
if (session.user && token.role) {
(session.user as Session["user"] & { role?: Role }).role =
token.role as Role;
}
return session;
},
},
pages: {
signIn: "/login",
},
@ -28,3 +76,17 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
maxAge: 24 * 60 * 60,
},
});
export async function requireRole(minRole: Role): Promise<Session | null> {
const session = await auth();
if (!session) return null;
const role = (session.user as { role?: Role } | undefined)?.role;
if (!role) return null;
if (minRole === "superadmin" && role !== "superadmin") return null;
return session;
}
export function sessionRole(session: Session | null | undefined): Role | null {
if (!session?.user) return null;
return ((session.user as { role?: Role }).role as Role) || null;
}

16
lib/use-role.ts Normal file
View file

@ -0,0 +1,16 @@
"use client";
import { useSession } from "next-auth/react";
export type Role = "admin" | "superadmin";
export function useRole(): { role: Role | null; isSuperadmin: boolean; authed: boolean } {
const { data: session, status } = useSession();
const role =
(session?.user as { role?: Role } | undefined)?.role ?? null;
return {
role,
isSuperadmin: role === "superadmin",
authed: status === "authenticated" && !!session,
};
}

18
types/next-auth.d.ts vendored Normal file
View file

@ -0,0 +1,18 @@
import type { DefaultSession } from "next-auth";
declare module "next-auth" {
interface User {
role?: "admin" | "superadmin";
}
interface Session {
user: {
role?: "admin" | "superadmin";
} & DefaultSession["user"];
}
}
declare module "next-auth/jwt" {
interface JWT {
role?: "admin" | "superadmin";
}
}