From 1ac5513d2cef7516022d932fd392f462305ddc24 Mon Sep 17 00:00:00 2001 From: hurkicorgi Date: Mon, 13 Apr 2026 06:52:05 -0600 Subject: [PATCH] Role-based access: admin vs superadmin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/api/players/route.ts | 9 ++++- components/Navbar.tsx | 13 +++++++ components/PlayerDrawer.tsx | 5 ++- components/PlayerManager.tsx | 34 ++++++++++++----- lib/auth.ts | 74 +++++++++++++++++++++++++++++++++--- lib/use-role.ts | 16 ++++++++ types/next-auth.d.ts | 18 +++++++++ 7 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 lib/use-role.ts create mode 100644 types/next-auth.d.ts diff --git a/app/api/players/route.ts b/app/api/players/route.ts index e01cba1..30ab04d 100644 --- a/app/api/players/route.ts +++ b/app/api/players/route.ts @@ -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}`; diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 5541600..fe8b179 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -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 (
@@ -24,6 +25,18 @@ export function Navbar() { {session ? ( <> + {role && ( + + {role === "superadmin" ? "Super" : "Admin"} + + )} diff --git a/components/PlayerDrawer.tsx b/components/PlayerDrawer.tsx index 9555768..1aeb0c9 100644 --- a/components/PlayerDrawer.tsx +++ b/components/PlayerDrawer.tsx @@ -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(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"} diff --git a/components/PlayerManager.tsx b/components/PlayerManager.tsx index e91eb87..956ce58 100644 --- a/components/PlayerManager.tsx +++ b/components/PlayerManager.tsx @@ -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("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() { {/* Player lists */} + {tab === "ops" && !isSuperadmin && ( +

+ Read-only — operator management requires super-admin. +

+ )} {tab === "ops" && (
    {data.ops.length === 0 && ( @@ -223,15 +235,17 @@ export function PlayerManager() { Lv{p.level} - + {isSuperadmin && ( + + )} ))}
diff --git a/lib/auth.ts b/lib/auth.ts index c799e29..a5e59be 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -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 { + 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; +} diff --git a/lib/use-role.ts b/lib/use-role.ts new file mode 100644 index 0000000..72490b6 --- /dev/null +++ b/lib/use-role.ts @@ -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, + }; +} diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts new file mode 100644 index 0000000..bf7bb4a --- /dev/null +++ b/types/next-auth.d.ts @@ -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"; + } +}