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