Add remaining public + admin pages (parallel build, 26 files)

Built via two parallel agent workflows reading the real Prisma schema, then
integrated + verified.

Public: /shop (categories + storefront), /community hub, /rankings (top by
credits), /staff, /photos (camera_web gallery), /help (categories + rules),
/help/tickets (auth-gated user tickets + create), /settings (auth-gated, update
motto via RCON).

Admin: /admin/catalog (+[id] items), /admin/radio (shouts/apps/schedules +
delete shout), /admin/teams (CRUD), /admin/permissions (read), /admin/wordfilter
(CRUD + RCON push), /admin/ip (whitelist/blacklist CRUD), /admin/applications
(list + dismiss), /admin/logs (chat/command/alert read), /admin/achievements
(read). Server actions all staff-gated.

Header + admin nav extended. Verified: tsc exit 0, vitest 48/48, next build
exit 0 (33 routes); curl-probed every route — public 200/<main>, auth+admin
correctly 307-redirect when unauthorized.
This commit is contained in:
Simo committed 2026-06-28 13:24:55 +02:00
1 parent 9a79acebe7
commit e96b606e1e
27 files changed
+2400

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function dismissApplication(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "").trim();
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
try {
await prisma.websiteStaffApplications.delete({ where: { id } });
} catch {
// already gone / no DB — nothing to do
}
revalidatePath("/admin/applications");
}
+52
View File
@@ -0,0 +1,52 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
function parseIp(formData: FormData): string {
return String(formData.get("ipAddress") ?? "").trim().slice(0, 255);
}
function parseAsn(formData: FormData): string | null {
const asn = String(formData.get("asn") ?? "").trim().slice(0, 255);
return asn || null;
}
export async function addWhitelist(formData: FormData): Promise<void> {
await requireStaff();
const ipAddress = parseIp(formData);
if (!ipAddress) return;
const asn = parseAsn(formData);
await prisma.websiteIpWhitelist.create({
data: { ipAddress, asn, whitelistAsn: asn != null },
});
revalidatePath("/admin/ip");
}
export async function deleteWhitelist(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "").trim();
if (!raw) return;
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
revalidatePath("/admin/ip");
}
export async function addBlacklist(formData: FormData): Promise<void> {
await requireStaff();
const ipAddress = parseIp(formData);
if (!ipAddress) return;
const asn = parseAsn(formData);
await prisma.websiteIpBlacklist.create({
data: { ipAddress, asn, blacklistAsn: asn != null },
});
revalidatePath("/admin/ip");
}
export async function deleteBlacklist(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "").trim();
if (!raw) return;
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
revalidatePath("/admin/ip");
}
+27
View File
@@ -0,0 +1,27 @@
'use server';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireStaff } from '@/lib/admin/guard';
export async function deleteShout(formData: FormData): Promise<void> {
await requireStaff();
const raw = formData.get('id');
if (typeof raw !== 'string' || raw.trim() === '') return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
try {
await prisma.radioShouts.delete({ where: { id } });
} catch {
// Row may already be gone; ignore so the action does not throw.
}
revalidatePath('/admin/radio');
}
+41
View File
@@ -0,0 +1,41 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function createTeam(formData: FormData): Promise<void> {
await requireStaff();
const rankName = String(formData.get("rankName") ?? "").trim();
if (!rankName) return;
const badge = String(formData.get("badge") ?? "").trim();
const jobDescription = String(formData.get("jobDescription") ?? "").trim();
const staffColor = String(formData.get("staffColor") ?? "").trim() || "#327fa8";
const hiddenRank = formData.get("hiddenRank") === "on";
const now = new Date();
await prisma.websiteTeams.create({
data: {
rankName: rankName.slice(0, 255),
badge: badge ? badge.slice(0, 255) : null,
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
staffColor: staffColor.slice(0, 255),
hiddenRank,
createdAt: now,
updatedAt: now,
},
});
revalidatePath("/admin/teams");
}
export async function deleteTeam(formData: FormData): Promise<void> {
await requireStaff();
const id = BigInt(String(formData.get("id")));
await prisma.websiteTeams.delete({ where: { id } });
revalidatePath("/admin/teams");
}
+34
View File
@@ -0,0 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
export async function addWord(formData: FormData): Promise<void> {
await requireStaff();
const word = String(formData.get("word") ?? "").trim().slice(0, 255);
if (!word) return;
try {
await prisma.websiteWordfilter.create({ data: { word } });
await rcon.updateWordFilter();
} catch {
// ignore (e.g. duplicate word) — page re-renders current state
}
revalidatePath("/admin/wordfilter");
}
export async function deleteWord(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!raw) return;
try {
await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } });
await rcon.updateWordFilter();
} catch {
// ignore (e.g. already removed)
}
revalidatePath("/admin/wordfilter");
}
+30
View File
@@ -0,0 +1,30 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function createTicket(formData: FormData): Promise<void> {
// Re-read the session user id server-side; never trust a form-supplied id.
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
const title = String(formData.get("title") ?? "").trim().slice(0, 255);
const content = String(formData.get("content") ?? "").trim().slice(0, 5000);
if (!title || !content) return;
const now = new Date();
await prisma.websiteHelpCenterTickets.create({
data: {
userId,
title,
content,
open: true,
createdAt: now,
updatedAt: now,
},
});
revalidatePath("/help/tickets");
}
+40
View File
@@ -0,0 +1,40 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
// Emulator motto column is VARCHAR(127); keep the CMS-side write within bounds.
const MOTTO_MAX = 127;
/**
* Update the SIGNED-IN user's motto. The id is taken from the session
* (re-fetched via auth()), never from the submitted FormData, so a crafted
* form cannot mutate another account. Mirrors AtomCMS: persist + RCON setmotto
* so an online user sees the change live.
*/
export async function updateMotto(formData: FormData): Promise<void> {
const session = await auth();
if (!session?.user?.id) return;
const id = Number(session.user.id);
if (!Number.isFinite(id)) return;
const motto = String(formData.get("motto") ?? "").slice(0, MOTTO_MAX);
try {
await prisma.user.update({ where: { id }, data: { motto } });
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
try {
await rcon.setMotto(id, motto);
} catch {
// RCON is best-effort; the change is already persisted.
}
revalidatePath("/settings");
}