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:
1 parent
686279eb25
commit
6eab5e5343
39 files changed
+841
-106
No files matched your search
@@ -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="✅" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user