Add admin foundation + Users resource (Filament replacement, slice 1)

Plain App Router admin (aligned to habbo-next, no Refine):
- rank surfaced on the NextAuth session; staff guard isStaff() [pure,
  unit-tested] + requireStaff() reading min_staff_rank, gating /admin.
- /admin dashboard (counts), /admin/users (paginated + search),
  /admin/users/[id] detail.
- src/actions/admin-users.ts: staff-gated server actions wiring the user editor
  to the existing services — giveCurrency (RCON or DB fallback), setMotto/setRank
  (DB + RCON), alertUser, disconnectUser.

Verified: tsc exit 0, vitest 45/45, next build exit 0 (/admin routes).
This commit is contained in:
Simo committed 2026-06-27 16:51:47 +02:00
1 parent d9a8ce532f
commit 9f81096f05
10 files changed
+334 -3

No files matched your search

+56
View File
@@ -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<string> = new Set(["credits", "duckets", "diamonds", "points"]);
export async function giveCurrency(formData: FormData): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await requireStaff();
const userId = Number(formData.get("userId"));
const username = String(formData.get("username") ?? "");
if (userId > 0) await rcon.disconnectUser(userId, username);
}