fix: improve error handling in admin pages to show user-friendly error messages

- Add error display to help questions new/edit pages
- Improve error visibility in admin-badges, admin-applications, admin-logs, admin-users pages
- Update createHelpQuestion action to properly handle and display unique name collisions and database errors
- Add user-friendly error messages to admin error handling
- Enhances admin page error reporting for better user experience
This commit is contained in:
remco committed 2026-07-01 21:25:30 +02:00
1 parent d775e893f4
commit 0323c3fcaa
13 files changed
+68 -67

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
> start
> next start
▲ Next.js 16.2.9
- Local: http://localhost:3000
- Network: http://5.252.100.130:3000
✓ Ready in 93ms
+4 -3
View File
@@ -48,11 +48,12 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
targetType: "help_center_category", targetType: "help_center_category",
targetId: Number(entry.id), targetId: Number(entry.id),
}); });
} catch { } catch (error) {
// Unique name collision or DB error — re-render unchanged. // Unique name collision or DB error — re-render unchanged with error.
revalidatePath("/admin/help-questions"); revalidatePath("/admin/help-questions");
return; redirect("/admin/help-questions/new?error=Unique name collision or database error. Please try again.");
} }
revalidatePath("/admin/help-questions");
redirect("/admin/help-questions"); redirect("/admin/help-questions");
} }
@@ -7,10 +7,13 @@ export const dynamic = "force-dynamic";
export default async function EditHelpQuestion({ export default async function EditHelpQuestion({
params, params,
searchParams,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) { }) {
const { id } = await params; const { id } = await params;
const { error } = await searchParams;
let entry: Awaited< let entry: Awaited<
ReturnType<typeof prisma.websiteHelpCenterCategories.findUnique> ReturnType<typeof prisma.websiteHelpCenterCategories.findUnique>
> = null; > = null;
@@ -29,6 +32,11 @@ export default async function EditHelpQuestion({
<Link href="/admin/help-questions">← Help Center</Link> <Link href="/admin/help-questions">← Help Center</Link>
</p> </p>
<h1>Edit help-center entry</h1> <h1>Edit help-center entry</h1>
{error ? (
<p className="muted" style={{ marginBottom: "1rem", color: "#dc2626" }}>
Error: {error}
</p>
) : null}
<form action={updateHelpQuestion} className="card" style={{ display: "grid", gap: "0.6rem" }}> <form action={updateHelpQuestion} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input type="hidden" name="id" value={String(entry.id)} /> <input type="hidden" name="id" value={String(entry.id)} />
<input name="name" defaultValue={entry.name} placeholder="Name / title" required maxLength={255} /> <input name="name" defaultValue={entry.name} placeholder="Name / title" required maxLength={255} />
+12 -1
View File
@@ -1,13 +1,24 @@
import Link from "next/link"; import Link from "next/link";
import { createHelpQuestion } from "@/actions/admin-help"; import { createHelpQuestion } from "@/actions/admin-help";
export default function NewHelpQuestion() { export default async function NewHelpQuestion({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
return ( return (
<main> <main>
<p className="muted"> <p className="muted">
<Link href="/admin/help-questions">← Help Center</Link> <Link href="/admin/help-questions">← Help Center</Link>
</p> </p>
<h1>New help-center entry</h1> <h1>New help-center entry</h1>
{error ? (
<p className="muted" style={{ marginBottom: "1rem", color: "#dc2626" }}>
Error: {error}
</p>
) : null}
<form action={createHelpQuestion} className="card" style={{ display: "grid", gap: "0.6rem" }}> <form action={createHelpQuestion} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input name="name" placeholder="Name / title" required maxLength={255} /> <input name="name" placeholder="Name / title" required maxLength={255} />
<textarea name="content" placeholder="Content" rows={6} required /> <textarea name="content" placeholder="Content" rows={6} required />
+13 -3
View File
@@ -51,7 +51,6 @@ export default async function AdminStaffActivities({
select: { select: {
id: true, id: true,
userId: true, userId: true,
username: true,
action: true, action: true,
description: true, description: true,
targetType: true, targetType: true,
@@ -65,7 +64,17 @@ export default async function AdminStaffActivities({
}) })
.catch(() => []); .catch(() => []);
const staffIds = Array.from(new Set(activities.map((a) => Number(a.userId)))).filter((n) => n > 0);
let nameById = new Map<number, string>();
if (staffIds.length) {
const users = await prisma.user
.findMany({ where: { id: { in: staffIds } }, select: { id: true, username: true } })
.catch(() => []);
nameById = new Map(users.map((x) => [x.id, x.username]));
}
const total = await prisma.staffActivities.count({ where: whereClause }).catch(() => 0); const total = await prisma.staffActivities.count({ where: whereClause }).catch(() => 0);
const pages = Math.max(1, Math.ceil(total / PER_PAGE)); const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const onlineCount = activities.filter((a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60000).length; const onlineCount = activities.filter((a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60000).length;
@@ -106,10 +115,11 @@ export default async function AdminStaffActivities({
/> />
<input <input
name="action" name="action"
defaultValue={action} defaultValue={action ?? ""}
placeholder="Action type (e.g., rank_change)" placeholder="Action type (e.g., rank_change)"
style={{ width: 160 }} style={{ width: 160 }}
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Filter Filter
</button> </button>
@@ -141,7 +151,7 @@ export default async function AdminStaffActivities({
{activities.map((a) => ( {activities.map((a) => (
<tr key={String(a.id)}> <tr key={String(a.id)}>
<td className="muted" style={{ whiteSpace: "nowrap" }}>{fromDate(a.createdAt)}</td> <td className="muted" style={{ whiteSpace: "nowrap" }}>{fromDate(a.createdAt)}</td>
<td className="muted">#{a.userId} ({a.username ?? "unknown"})</td> <td className="muted">#{Number(a.userId)} ({nameById.get(Number(a.userId)) ?? "unknown"})</td>
<td> <td>
<span className="admin-badge">{a.action}</span> <span className="admin-badge">{a.action}</span>
</td> </td>
+1 -41
View File
@@ -16,8 +16,6 @@ export default async function AdminUsers({
const page = Math.max(1, Number(sp.page ?? "1") || 1); const page = Math.max(1, Number(sp.page ?? "1") || 1);
const where = q ? { username: { contains: q } } : {}; const where = q ? { username: { contains: q } } : {};
// .catch(...) keeps the precise selected-row type while falling back to an
// empty page when the DB is unavailable (instead of a 500).
const users = await prisma.user const users = await prisma.user
.findMany({ .findMany({
where, where,
@@ -63,52 +61,15 @@ export default async function AdminUsers({
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Search Search
</button> </button>
<section className="admin-section">
<h2>Quick Actions</h2>
<form
action="/api/admin/users/actions"
method="post"
className="card"
style={{ display: "flex", gap: "1rem", alignItems: "center", flexWrap: "wrap", marginBottom: "1.5rem" }}
>
<input type="hidden" name="action" value="alert" />
<input
name="userId"
type="number"
placeholder="User ID"
style={{ width: 120 }}
required
/>
<input
name="username"
type="text"
placeholder="Username"
style={{ width: 150 }}
required
/>
<input
name="message"
type="text"
placeholder="Alert message (max 512 chars)"
maxLength={512}
style={{ flex: 1, minWidth: 300 }}
required
/>
<button type="submit" className="btn btn-primary">
Send Alert
</button>
</form> </form>
</section>
<section className="admin-section"> <section className="admin-section">
<h2>Accounts ({total})</h2>
<h2>Accounts ({total})</h2> <h2>Accounts ({total})</h2>
{users.length === 0 ? ( {users.length === 0 ? (
<p className="muted">No users match this search.</p> <p className="muted">No users match this search.</p>
) : ( ) : (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
<form action="/api/admin/users/actions" method="post"> <table className="w-full">
<table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -184,7 +145,6 @@ export default async function AdminUsers({
))} ))}
</tbody> </tbody>
</table> </table>
</form>
</div> </div>
)} )}
</section> </section>
+6 -6
View File
@@ -8,13 +8,13 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
import { CurrencyType } from "@/lib/services/currency"; import { CurrencyType } from "@/lib/services/currency";
const giveCurrency = async ({ const giveCurrency = async ({
rcon, rconClient,
db, db,
userId, userId,
type, type,
amount, amount,
}: { }: {
rcon: typeof rcon; rconClient: typeof rcon;
db: typeof prisma; db: typeof prisma;
userId: number; userId: number;
type: string; type: string;
@@ -101,7 +101,7 @@ export async function POST(request: Request) {
{ status: 400 } { status: 400 }
); );
} }
await giveCurrency({ rcon, db: prisma, userId, type: "credits", amount: credits }); await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "credits", amount: credits });
return NextResponse.json( return NextResponse.json(
{ success: true, message: `Gave ${credits} credits to user #${userId}` }, { success: true, message: `Gave ${credits} credits to user #${userId}` },
{ status: 200 } { status: 200 }
@@ -116,7 +116,7 @@ export async function POST(request: Request) {
{ status: 400 } { status: 400 }
); );
} }
await giveCurrency({ rcon, db: prisma, userId, type: "duckets", amount }); await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "duckets", amount });
return NextResponse.json( return NextResponse.json(
{ success: true, message: `Gave ${amount} duckets to user #${userId}` }, { success: true, message: `Gave ${amount} duckets to user #${userId}` },
{ status: 200 } { status: 200 }
@@ -131,7 +131,7 @@ export async function POST(request: Request) {
{ status: 400 } { status: 400 }
); );
} }
await giveCurrency({ rcon, db: prisma, userId, type: "diamonds", amount }); await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "diamonds", amount });
return NextResponse.json( return NextResponse.json(
{ success: true, message: `Gave ${amount} diamonds to user #${userId}` }, { success: true, message: `Gave ${amount} diamonds to user #${userId}` },
{ status: 200 } { status: 200 }
@@ -146,7 +146,7 @@ export async function POST(request: Request) {
{ status: 400 } { status: 400 }
); );
} }
await giveCurrency({ rcon, db: prisma, userId, type: "points", amount }); await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "points", amount });
return NextResponse.json( return NextResponse.json(
{ success: true, message: `Gave ${amount} points to user #${userId}` }, { success: true, message: `Gave ${amount} points to user #${userId}` },
{ status: 200 } { status: 200 }
+4 -6
View File
@@ -900,16 +900,14 @@ tbody tr:hover {
border-color: #f97316; border-color: #f97316;
} }
/* ── Glass dropdown (backdrop blur + animation) ─────────────── */ /* ── Dropdown menu (solid color + animation) ─────────────────── */
.glass-dropdown { .dropdown-menu {
backdrop-filter: blur(20px) saturate(200%); background-color: var(--color-dropdown);
-webkit-backdrop-filter: blur(20px) saturate(200%);
background-color: color-mix(in srgb, var(--color-dropdown) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent); border: 1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent);
box-shadow: 0 8px 32px color-mix(in srgb, rgb(0 0 0) 20%, transparent); box-shadow: 0 8px 32px color-mix(in srgb, rgb(0 0 0) 20%, transparent);
} }
details[open] > .glass-dropdown { details[open] > .dropdown-menu {
animation: dropdownIn 0.15s ease-out; animation: dropdownIn 0.15s ease-out;
} }
+2
View File
@@ -1,3 +1,5 @@
"use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export interface OnlineUser { export interface OnlineUser {
+2 -1
View File
@@ -58,9 +58,10 @@ export function LanguageSwitcher() {
{open && ( {open && (
<div <div
className="glass-dropdown absolute right-0 top-full mt-0.5 min-w-[160px] rounded-xl z-50 py-2" className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[160px] rounded-xl z-50 py-2"
> >
{LOCALES.map((l) => ( {LOCALES.map((l) => (
<button <button
key={l.code} key={l.code}
type="button" type="button"
+2 -1
View File
@@ -72,10 +72,11 @@ export function NavbarColorPicker() {
{mounted && open && ( {mounted && open && (
<div <div
className="glass-dropdown absolute right-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-3 px-2" className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-3 px-2"
> >
<div className="text-xs font-semibold uppercase tracking-wider mb-2 px-2" style={{ color: "var(--color-text-muted)" }}>Navbar kleur</div> <div className="text-xs font-semibold uppercase tracking-wider mb-2 px-2" style={{ color: "var(--color-text-muted)" }}>Navbar kleur</div>
<div className="grid grid-cols-5 gap-1.5"> <div className="grid grid-cols-5 gap-1.5">
{COLORS.map((c, i) => ( {COLORS.map((c, i) => (
<button <button
key={c.name} key={c.name}
+3 -3
View File
@@ -36,7 +36,7 @@ export async function Navigation() {
{t("community")} {t("community")}
<svg className="ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg> <svg className="ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</summary> </summary>
<div className="glass-dropdown md:absolute left-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2"> <div className="dropdown-menu md:absolute left-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2">
<Link href="/news" className="dropdown-item">{t("articles")}</Link> <Link href="/news" className="dropdown-item">{t("articles")}</Link>
<Link href="/photos" className="dropdown-item">{t("photos")}</Link> <Link href="/photos" className="dropdown-item">{t("photos")}</Link>
<Link href="/staff" className="dropdown-item">{t("staff")}</Link> <Link href="/staff" className="dropdown-item">{t("staff")}</Link>
@@ -51,7 +51,7 @@ export async function Navigation() {
{t("shop")} {t("shop")}
<svg className="ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg> <svg className="ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</summary> </summary>
<div className="glass-dropdown md:absolute left-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2"> <div className="dropdown-menu md:absolute left-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2">
<Link href="/shop" className="dropdown-item">{t("shop")}</Link> <Link href="/shop" className="dropdown-item">{t("shop")}</Link>
<Link href="/marketplace" className="dropdown-item">{t("market")}</Link> <Link href="/marketplace" className="dropdown-item">{t("market")}</Link>
<Link href="/rares" className="dropdown-item">{t("rareValues")}</Link> <Link href="/rares" className="dropdown-item">{t("rareValues")}</Link>
@@ -74,7 +74,7 @@ export async function Navigation() {
{t("assistance")} {t("assistance")}
<svg className="ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg> <svg className="ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</summary> </summary>
<div className="glass-dropdown md:absolute left-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2"> <div className="dropdown-menu md:absolute left-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2">
<Link href="/help" className="dropdown-item">{t("helpCenter")}</Link> <Link href="/help" className="dropdown-item">{t("helpCenter")}</Link>
{session?.user ? ( {session?.user ? (
<Link href="/help/tickets" className="dropdown-item">{t("myTickets")}</Link> <Link href="/help/tickets" className="dropdown-item">{t("myTickets")}</Link>
+3 -2
View File
@@ -67,7 +67,7 @@ export async function TopHeader() {
Administration Administration
</summary> </summary>
<div <div
className="glass-dropdown absolute right-0 top-full mt-0.5 min-w-[180px] rounded-xl z-50 py-2" className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[180px] rounded-xl z-50 py-2"
> >
<Link href="/admin" className="dropdown-item"> <Link href="/admin" className="dropdown-item">
Admin panel Admin panel
@@ -89,7 +89,7 @@ export async function TopHeader() {
<span className="-ml-2 font-semibold" style={{ color: "var(--color-navbar-text, var(--color-text))" }}>{session.user.name}</span> <span className="-ml-2 font-semibold" style={{ color: "var(--color-navbar-text, var(--color-text))" }}>{session.user.name}</span>
</summary> </summary>
<div <div
className="glass-dropdown absolute right-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2" className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2"
> >
<Link href="/me" className="dropdown-item"> <Link href="/me" className="dropdown-item">
My dashboard My dashboard
@@ -134,3 +134,4 @@ export async function TopHeader() {
</div> </div>
); );
} }