feat(admin): ACL repair, mod users, ticket clarity, ops online hub

Add Repair nav grants on permissions, /mod/users without email/IP, shared ticket queue banners, and shared online roster on CommandoCentrum.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-30 19:37:01 +02:00
1 parent 686279eb25
commit 6eab5e5343
39 files changed
+841 -106

No files matched your search

+6 -25
View File
@@ -7,6 +7,7 @@ import {
OnlineUsersWidget,
StatusCard,
} from "@/components/admin/dashboard";
import { fetchOpsOnlineUsers } from "@/lib/admin/ops-online-users";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
@@ -46,16 +47,14 @@ export default async function CommandoCentrum() {
const t = await getTranslations("pages.admin.commandocentrum");
const [
onlineCount,
totalUsers,
errors,
dbOk,
emulatorOnline,
maintenance,
activity,
onlineUsersRaw,
onlineRoster,
] = await Promise.all([
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
prisma.user.count().catch(() => -1),
prisma.emulatorErrors
.findMany({ orderBy: { timestamp: "desc" }, take: 50 })
@@ -71,29 +70,11 @@ export default async function CommandoCentrum() {
.catch(
() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>,
),
prisma.user
.findMany({
where: { online: "1" },
select: {
id: true,
username: true,
look: true,
lastOnline: true,
},
orderBy: { lastOnline: "desc" },
take: 40,
})
.catch(() => []),
fetchOpsOnlineUsers(40),
]);
const onlineUsers = onlineUsersRaw.map((u) => ({
id: u.id,
username: u.username,
look: u.look,
roomId: null as number | null,
roomName: null as string | null,
lastActivity: u.lastOnline > 0 ? new Date(u.lastOnline * 1000) : new Date(),
}));
const onlineCount = onlineRoster.count;
const onlineUsers = onlineRoster.users;
const staffIds = Array.from(
new Set(activity.map((a) => Number(a.userId))),
@@ -155,7 +136,7 @@ export default async function CommandoCentrum() {
</div>
{/* ── Online users widget ───────────────────────────────── */}
<section className="admin-card mb-6">
<section id="online" className="admin-card mb-6 scroll-mt-4">
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
<OnlineUsersWidget users={onlineUsers} isLoading={false} />
</section>
+8 -2
View File
@@ -1,4 +1,5 @@
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { Badge } from "@/components/ui/badge";
@@ -100,8 +101,13 @@ export default async function DevOpsPage() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.onlineUsers}</div>
<p className="text-xs text-muted-foreground">
Current active connections
<p className="text-xs text-muted-foreground mt-1">
<Link
href="/admin/commandocentrum#online"
className="text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
Live roster in CommandoCentrum
</Link>
</p>
</CardContent>
</Card>
+6 -14
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
import type { Prisma } from "@/generated/prisma/client";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { formatDate } from "@/lib/format-date";
@@ -160,20 +161,11 @@ export default async function AdminHelpTicketsPage({
return (
<div className="space-y-6">
<div
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm"
role="status"
>
<p className="m-0">{t("queueHint")}</p>
<p className="m-0 mt-1">
<a
href="/admin/tickets"
className="font-medium text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
{t("queueOtherCta")}
</a>
</p>
</div>
<TicketQueueBanner
hint={t("queueHint")}
otherHref="/admin/tickets"
otherLabel={t("queueOtherCta")}
/>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
+16 -7
View File
@@ -83,13 +83,22 @@ export default async function AdminDashboard() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatusCard
label={t("playersOnline")}
value={online}
hint={dbOk ? t("totalAccounts", { count: users }) : t("databaseDown")}
state={dbOk ? "neutral" : "danger"}
icon={<Users size={18} />}
/>
<Link
href="/admin/commandocentrum#online"
className="no-underline text-inherit"
>
<StatusCard
label={t("playersOnline")}
value={online}
hint={
dbOk
? `${t("totalAccounts", { count: users })} · live roster →`
: t("databaseDown")
}
state={dbOk ? "neutral" : "danger"}
icon={<Users size={18} />}
/>
</Link>
<StatusCard
label={t("articles")}
value={dbOk ? articles : "—"}
+25 -6
View File
@@ -14,7 +14,11 @@ import {
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { createRank, deleteRank } from "@/actions/permissions";
import {
createRank,
deleteRank,
repairAdminNavAclGrants,
} from "@/actions/permissions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -123,15 +127,30 @@ export function PermissionsList({
</div>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-muted-foreground">
{ranks.length} ranks &middot; {totalUsers.toLocaleString()} users
total
</p>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-1.5" />
New Rank
</Button>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={isPending}
onClick={() =>
run(() => repairAdminNavAclGrants(), {
successMessage: "Admin nav ACL grants repaired",
})
}
title="Fill missing admin.* grants for ranks that already have dashboard access (same as migration 0018)"
>
<ShieldCheck className="h-4 w-4 mr-1.5" />
Repair nav grants
</Button>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-1.5" />
New Rank
</Button>
</div>
</div>
{/* Rank cards */}
+6 -14
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
import type { Prisma } from "@/generated/prisma/client";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { formatDate } from "@/lib/format-date";
@@ -116,20 +117,11 @@ export default async function AdminTicketsPage({
return (
<div className="space-y-6">
<div
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm"
role="status"
>
<p className="m-0">{t("queueHint")}</p>
<p className="m-0 mt-1">
<a
href="/admin/help-tickets"
className="font-medium text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
{t("queueOtherCta")}
</a>
</p>
</div>
<TicketQueueBanner
hint={t("queueHint")}
otherHref="/admin/help-tickets"
otherLabel={t("queueOtherCta")}
/>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
<StatusCard
+6
View File
@@ -4,6 +4,7 @@ import {
HelpTicketsTable,
} from "@/app/admin/help-tickets/help-tickets-table";
import { StatusCard } from "@/components/admin/dashboard";
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
import type { Prisma } from "@/generated/prisma/client";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
@@ -160,6 +161,11 @@ export default async function ModHelpTicketsPage({
return (
<div className="space-y-6">
<TicketQueueBanner
hint={t("queueHint")}
otherHref="/mod/tickets"
otherLabel={t("queueOtherCta")}
/>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
+10
View File
@@ -6,6 +6,7 @@ import {
Radio,
Shield,
Ticket,
UserRound,
Users,
} from "lucide-react";
import Link from "next/link";
@@ -57,6 +58,9 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
const showTeam =
canAccess(permissions, PERMS.MOD_TEAM_VIEW, staff.rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, staff.rank);
const showUsers =
canAccess(permissions, PERMS.MOD_USERS_VIEW, staff.rank) ||
canAccess(permissions, PERMS.USERS_VIEW, staff.rank);
const showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank);
const nav = [
@@ -81,6 +85,12 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
show: showActions,
},
{ href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans },
{
href: "/mod/users",
label: t("nav.users"),
icon: UserRound,
show: showUsers,
},
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
].filter((item) => item.show);
+10
View File
@@ -37,6 +37,9 @@ export default async function ModDashboardPage() {
const showTeam =
canAccess(permissions, PERMS.MOD_TEAM_VIEW, rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, rank);
const showUsers =
canAccess(permissions, PERMS.MOD_USERS_VIEW, rank) ||
canAccess(permissions, PERMS.USERS_VIEW, rank);
const [openCfh, todayBans, modsOnline, openTickets, openHelpTickets] =
await Promise.all([
@@ -178,6 +181,13 @@ export default async function ModDashboardPage() {
</Link>
</li>
) : null}
{showUsers ? (
<li>
<Link href="/mod/users" className="text-primary hover:underline">
{t("nav.users")}
</Link>
</li>
) : null}
{showTeam ? (
<li>
<Link href="/mod/team" className="text-primary hover:underline">
+6
View File
@@ -4,6 +4,7 @@ import {
TicketsTable,
} from "@/app/admin/tickets/tickets-table";
import { StatusCard } from "@/components/admin/dashboard";
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
import type { Prisma } from "@/generated/prisma/client";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
@@ -116,6 +117,11 @@ export default async function ModTicketsPage({
return (
<div className="space-y-6">
<TicketQueueBanner
hint={t("queueHint")}
otherHref="/mod/help-tickets"
otherLabel={t("queueOtherCta")}
/>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
<StatusCard
+132
View File
@@ -0,0 +1,132 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { Badge } from "@/components/ui/badge";
import { requireModPermission } from "@/lib/admin/guard";
import { formatDate } from "@/lib/format-date";
import { getAvatarUrl } from "@/lib/imager";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function ModUserDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireModPermission([PERMS.MOD_USERS_VIEW, PERMS.USERS_VIEW]);
const t = await getTranslations("pages.mod");
const { id: rawId } = await params;
const id = Number(rawId);
if (!Number.isFinite(id) || id <= 0) notFound();
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
username: true,
rank: true,
online: true,
motto: true,
look: true,
accountCreated: true,
lastLogin: true,
lastOnline: true,
},
});
if (!user) notFound();
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4">
<img
src={getAvatarUrl(user.look, {
size: "l",
direction: 2,
headDirection: 2,
})}
alt=""
width={64}
height={110}
className="rounded"
/>
<div>
<h2 className="admin-section-title m-0">{user.username}</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
#{user.id} · {t("usersRank")} {user.rank}
</p>
<div className="mt-2 flex gap-2">
{user.online === "1" ? (
<Badge className="bg-[var(--admin-success)]">Online</Badge>
) : (
<Badge variant="outline">Offline</Badge>
)}
</div>
</div>
</div>
<div className="flex gap-3">
<Link
href="/mod/users"
className="text-sm text-muted-foreground underline-offset-2 hover:underline"
>
← {t("nav.users")}
</Link>
<Link
href={`/u/${user.username}`}
className="text-sm text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
{t("usersPublicProfile")}
</Link>
</div>
</div>
<div
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm"
role="status"
>
{t("usersPiiNote")}
</div>
<div className="admin-card grid gap-4 p-5 sm:grid-cols-2">
<div>
<p className="m-0 text-xs uppercase tracking-wide text-muted-foreground">
Motto
</p>
<p className="m-0 mt-1">{user.motto || "—"}</p>
</div>
<div>
<p className="m-0 text-xs uppercase tracking-wide text-muted-foreground">
{t("usersJoined")}
</p>
<p className="m-0 mt-1">
{user.accountCreated > 0
? formatDate(new Date(user.accountCreated * 1000), "date")
: "—"}
</p>
</div>
<div>
<p className="m-0 text-xs uppercase tracking-wide text-muted-foreground">
{t("usersLastLogin")}
</p>
<p className="m-0 mt-1">
{user.lastLogin
? formatDate(new Date(user.lastLogin * 1000), "datetime")
: "—"}
</p>
</div>
<div>
<p className="m-0 text-xs uppercase tracking-wide text-muted-foreground">
{t("usersLastOnline")}
</p>
<p className="m-0 mt-1">
{user.lastOnline > 0
? formatDate(new Date(user.lastOnline * 1000), "datetime")
: "—"}
</p>
</div>
</div>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { getAvatarUrl } from "@/lib/imager";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import type { DataTableColumn } from "@/types/common";
export const dynamic = "force-dynamic";
type ModUserRow = {
id: number;
username: string;
rank: number;
online: string;
motto: string;
look: string;
lastLogin: number | null;
};
const columns: DataTableColumn<ModUserRow>[] = [
{ key: "id", label: "ID", sortable: true },
{
key: "username",
label: "User",
sortable: true,
render: (_, row) => (
<div className="flex items-center gap-2.5">
<img
src={getAvatarUrl(row.look, {
size: "m",
direction: 2,
headDirection: 2,
})}
alt=""
width={32}
height={32}
className="rounded"
/>
<span className="font-medium">{row.username}</span>
</div>
),
},
{
key: "rank",
label: "Rank",
sortable: true,
render: (v) => <Badge variant="secondary">#{String(v)}</Badge>,
},
{
key: "online",
label: "Online",
render: (v) =>
v === "1" ? (
<Badge className="bg-[var(--admin-success)]">Online</Badge>
) : (
<Badge variant="outline">Offline</Badge>
),
},
{
key: "motto",
label: "Motto",
render: (v) => (
<span className="text-muted-foreground truncate max-w-[220px] block">
{String(v || "—")}
</span>
),
},
{
key: "actions",
label: "",
render: (_, row) => (
<div className="flex gap-2 justify-end">
<Link
href={`/mod/users/${row.id}`}
className="text-sm text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
Mod view
</Link>
<Link
href={`/u/${row.username}`}
className="text-sm text-muted-foreground underline-offset-2 hover:underline"
>
Public
</Link>
</div>
),
},
];
export default async function ModUsersPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
await requireModPermission([PERMS.MOD_USERS_VIEW, PERMS.USERS_VIEW]);
const t = await getTranslations("pages.mod");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const where = parsed.search.trim()
? {
OR: [
{ username: { contains: parsed.search.trim() } },
{ motto: { contains: parsed.search.trim() } },
...(Number.isFinite(Number(parsed.search)) &&
Number(parsed.search) > 0
? [{ id: Number(parsed.search) }]
: []),
],
}
: {};
const orderBy = parsed.sort
? { [parsed.sort]: parsed.order }
: { id: "desc" as const };
const [total, users] = await Promise.all([
prisma.user.count({ where }).catch(() => 0),
prisma.user
.findMany({
where,
orderBy,
skip: (parsed.page - 1) * parsed.perPage,
take: parsed.perPage,
select: {
id: true,
username: true,
rank: true,
online: true,
motto: true,
look: true,
lastLogin: true,
},
})
.catch(() => []),
]);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const rows: ModUserRow[] = users;
return (
<div className="space-y-6">
<div>
<h2 className="admin-section-title m-0">{t("usersTitle")}</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
{t("usersSubtitle")}
</p>
</div>
{total === 0 && !parsed.search ? (
<div className="admin-empty">{t("usersEmpty")}</div>
) : (
<DataTable
columns={columns}
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
searchPlaceholder={t("usersSearchPlaceholder")}
/>
)}
</div>
);
}