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:
1 parent
9a79acebe7
commit
e96b606e1e
27 files changed
+2400
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user