diff --git a/src/actions/admin-users.ts b/src/actions/admin-users.ts new file mode 100644 index 00000000..afcf2518 --- /dev/null +++ b/src/actions/admin-users.ts @@ -0,0 +1,56 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { rcon } from "@/lib/services/rcon"; +import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency"; + +const CURRENCIES: ReadonlySet = new Set(["credits", "duckets", "diamonds", "points"]); + +export async function giveCurrency(formData: FormData): Promise { + await requireStaff(); + const userId = Number(formData.get("userId")); + const type = String(formData.get("type")); + const amount = Number(formData.get("amount")); + if (userId > 0 && amount > 0 && CURRENCIES.has(type)) { + await sendCurrency({ rcon, db: prisma }, userId, type as CurrencyName, amount); + } + revalidatePath(`/admin/users/${userId}`); +} + +export async function setMotto(formData: FormData): Promise { + await requireStaff(); + const userId = Number(formData.get("userId")); + const motto = String(formData.get("motto") ?? "").slice(0, 127); + if (userId > 0) { + await prisma.user.update({ where: { id: userId }, data: { motto } }); + await rcon.setMotto(userId, motto); + } + revalidatePath(`/admin/users/${userId}`); +} + +export async function setRank(formData: FormData): Promise { + await requireStaff(); + const userId = Number(formData.get("userId")); + const rank = Number(formData.get("rank")); + if (userId > 0 && rank > 0) { + await prisma.user.update({ where: { id: userId }, data: { rank } }); + await rcon.setRank(userId, rank); + } + revalidatePath(`/admin/users/${userId}`); +} + +export async function alertUser(formData: FormData): Promise { + await requireStaff(); + const userId = Number(formData.get("userId")); + const message = String(formData.get("message") ?? "").trim(); + if (userId > 0 && message) await rcon.alertUser(userId, message); +} + +export async function disconnectUser(formData: FormData): Promise { + await requireStaff(); + const userId = Number(formData.get("userId")); + const username = String(formData.get("username") ?? ""); + if (userId > 0) await rcon.disconnectUser(userId, username); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 00000000..643937e1 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import { requireStaff } from "@/lib/admin/guard"; + +export const dynamic = "force-dynamic"; + +export default async function AdminLayout({ children }: { children: ReactNode }) { + const staff = await requireStaff(); + + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 00000000..86410174 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,33 @@ +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export default async function AdminDashboard() { + const [users, online, articles] = await Promise.all([ + prisma.user.count(), + prisma.user.count({ where: { online: "1" } }), + prisma.websiteArticles.count(), + ]); + + const stats = [ + { label: "Users", value: users }, + { label: "Online now", value: online }, + { label: "Articles", value: articles }, + ]; + + return ( +
+

Dashboard

+
+ {stats.map((s) => ( +
+

{s.value}

+

+ {s.label} +

+
+ ))} +
+
+ ); +} diff --git a/src/app/admin/users/[id]/page.tsx b/src/app/admin/users/[id]/page.tsx new file mode 100644 index 00000000..f2b4887a --- /dev/null +++ b/src/app/admin/users/[id]/page.tsx @@ -0,0 +1,82 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { + alertUser, + disconnectUser, + giveCurrency, + setMotto, + setRank, +} from "@/actions/admin-users"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export default async function AdminUserDetail({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const userId = Number(id); + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, motto: true, rank: true, credits: true, look: true, online: true }, + }); + if (!user) notFound(); + + return ( +
+

+ ← Users +

+

{user.username}

+

+ ID {user.id} · rank {user.rank} · {user.credits} credits ·{" "} + {user.online === "1" ? "Online" : "Offline"} +

+ +
+
+ +

Give currency

+ {" "} + {" "} + +
+ +
+ +

Set motto

+ {" "} + +
+ +
+ +

Set rank

+ {" "} + +
+ +
+ +

Alert

+ {" "} + +
+ +
+ + +

Disconnect

+ +
+
+
+ ); +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 00000000..a2009408 --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,74 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +const PER_PAGE = 25; + +export default async function AdminUsers({ + searchParams, +}: { + searchParams: Promise<{ q?: string; page?: string }>; +}) { + const sp = await searchParams; + const q = (sp.q ?? "").trim(); + const page = Math.max(1, Number(sp.page ?? "1") || 1); + const where = q ? { username: { contains: q } } : {}; + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + select: { id: true, username: true, rank: true, credits: true, online: true }, + orderBy: { id: "desc" }, + skip: (page - 1) * PER_PAGE, + take: PER_PAGE, + }), + prisma.user.count({ where }), + ]); + const pages = Math.max(1, Math.ceil(total / PER_PAGE)); + + return ( +
+

Users

+
+ + +
+ + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
IDUsernameRankCreditsOnline
{u.id} + {u.username} + {u.rank}{u.credits}{u.online === "1" ? "●" : "○"}
+ +

+ Page {page} / {pages} · {total} users + {page < pages ? ( + <> + {" · "} + Next → + + ) : null} +

+
+ ); +} diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts new file mode 100644 index 00000000..d4a97c6f --- /dev/null +++ b/src/lib/admin/guard.ts @@ -0,0 +1,33 @@ +import { redirect } from "next/navigation"; +import { isStaff } from "@/lib/admin/is-staff"; +import { auth } from "@/lib/auth"; +import { siteSettings } from "@/lib/services/site-settings"; + +export { isStaff }; + +export async function getMinStaffRank(): Promise { + const n = Number(await siteSettings.get("min_staff_rank", "7")); + return Number.isFinite(n) ? n : 7; +} + +export interface StaffUser { + id: number; + rank: number; + username: string; +} + +/** + * Gate for admin pages and actions: redirects to /login when unauthenticated + * and to / when authenticated but not staff. Returns the staff user otherwise. + */ +export async function requireStaff(): Promise { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + const minRank = await getMinStaffRank(); + if (!isStaff(session.user.rank, minRank)) redirect("/"); + return { + id: Number(session.user.id), + rank: session.user.rank, + username: session.user.name ?? "", + }; +} diff --git a/src/lib/admin/is-staff.test.ts b/src/lib/admin/is-staff.test.ts new file mode 100644 index 00000000..254a0c73 --- /dev/null +++ b/src/lib/admin/is-staff.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { isStaff } from "./is-staff"; + +describe("isStaff", () => { + it("is true at or above the min staff rank", () => { + expect(isStaff(7, 7)).toBe(true); + expect(isStaff(10, 7)).toBe(true); + }); + + it("is false below the min staff rank", () => { + expect(isStaff(6, 7)).toBe(false); + expect(isStaff(1, 7)).toBe(false); + }); +}); diff --git a/src/lib/admin/is-staff.ts b/src/lib/admin/is-staff.ts new file mode 100644 index 00000000..ac937916 --- /dev/null +++ b/src/lib/admin/is-staff.ts @@ -0,0 +1,4 @@ +/** AtomCMS housekeeping gate: staff are users with rank >= min_staff_rank. */ +export function isStaff(rank: number, minStaffRank: number): boolean { + return rank >= minStaffRank; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index c271a9a3..bbe27cd4 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -35,14 +35,19 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ }); } - return { id: String(user.id), name: user.username }; + return { id: String(user.id), name: user.username, rank: user.rank }; }, }), ], callbacks: { - // NextAuth stores the user id in token.sub automatically; surface it on the session. + jwt({ token, user }) { + if (user) token.rank = (user as { rank?: number }).rank; + return token; + }, + // NextAuth stores the user id in token.sub automatically; surface id + rank. session({ session, token }) { if (token.sub && session.user) session.user.id = token.sub; + if (typeof token.rank === "number" && session.user) session.user.rank = token.rank; return session; }, }, diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 31df6825..6fa3755b 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -2,6 +2,12 @@ import type { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { - user: { id: string } & DefaultSession["user"]; + user: { id: string; rank: number } & DefaultSession["user"]; + } +} + +declare module "next-auth/jwt" { + interface JWT { + rank?: number; } }