feat(mod): tickets+team tabs; retire HK writes for live ACL
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
084cca6ea4
commit
75cace41ea
16 files changed
+735
-1002
No files matched your search
File diff suppressed because it is too large.
Load diff
+16
-4
@@ -121,7 +121,10 @@ export const closeTicketByUser = authAction(
|
||||
// ── Admin actions ────────────────────────────────────────────────────
|
||||
|
||||
export const adminReplyTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema },
|
||||
{
|
||||
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||
schema: replyTicketSchema,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
@@ -161,7 +164,10 @@ export const adminReplyTicket = adminAction(
|
||||
);
|
||||
|
||||
export const updateTicketStatus = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
|
||||
{
|
||||
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||
schema: updateTicketStatusSchema,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
@@ -196,7 +202,10 @@ export const updateTicketStatus = adminAction(
|
||||
);
|
||||
|
||||
export const assignTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
|
||||
{
|
||||
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||
schema: assignTicketSchema,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
@@ -226,7 +235,10 @@ export const assignTicket = adminAction(
|
||||
);
|
||||
|
||||
export const updateTicketPriority = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
|
||||
{
|
||||
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||
schema: updateTicketPrioritySchema,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
|
||||
+111
-118
File diff suppressed because it is too large.
Load diff
@@ -5,16 +5,20 @@ import { prisma } from "@/lib/prisma";
|
||||
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
||||
import { PermissionsList } from "./permissions-list";
|
||||
|
||||
export default async function PermissionsPage(_props: {
|
||||
export default async function PermissionsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams?: Promise<{ from?: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (
|
||||
!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)
|
||||
) {
|
||||
if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const sp = (await searchParams) ?? {};
|
||||
const fromHousekeeping = sp.from === "housekeeping";
|
||||
|
||||
const ranks = await fetchEmulatorRankSummaries(prisma);
|
||||
|
||||
// User counts per rank
|
||||
@@ -31,5 +35,10 @@ export default async function PermissionsPage(_props: {
|
||||
userCount: countMap[rank.id] ?? 0,
|
||||
}));
|
||||
|
||||
return <PermissionsList ranks={ranksWithCounts} />;
|
||||
return (
|
||||
<PermissionsList
|
||||
ranks={ranksWithCounts}
|
||||
fromHousekeeping={fromHousekeeping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ interface RankSummary {
|
||||
|
||||
interface Props {
|
||||
ranks: RankSummary[];
|
||||
fromHousekeeping?: boolean;
|
||||
}
|
||||
|
||||
const RANK_ICONS: Record<number, React.ReactNode> = {
|
||||
@@ -58,7 +59,10 @@ const RANK_COLORS: Record<number, string> = {
|
||||
4: "border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]",
|
||||
};
|
||||
|
||||
export function PermissionsList({ ranks: initialRanks }: Props) {
|
||||
export function PermissionsList({
|
||||
ranks: initialRanks,
|
||||
fromHousekeeping = false,
|
||||
}: Props) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [ranks, setRanks] = useState(initialRanks);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RankSummary | null>(null);
|
||||
@@ -98,14 +102,23 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{fromHousekeeping ? (
|
||||
<div
|
||||
className="rounded-lg border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-4 py-3 text-sm"
|
||||
role="status"
|
||||
>
|
||||
Housekeeping writes are retired. Edit live ACL ranks and permissions
|
||||
here.
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm text-[var(--admin-text)]"
|
||||
role="status"
|
||||
>
|
||||
<p className="m-0 font-semibold">Live ACL</p>
|
||||
<p className="m-0 mt-1 text-muted-foreground">
|
||||
Rank ACL here controls real admin access. The Housekeeping table is
|
||||
legacy reference only and does not gate routes.
|
||||
Rank ACL here controls real admin and mod access. The Housekeeping
|
||||
table is a read-only legacy archive with an ACL compare view.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -85,18 +85,29 @@ export function AdminTicketDetail({
|
||||
staffMembers,
|
||||
currentUserId,
|
||||
canEdit,
|
||||
listHref = "/admin/tickets",
|
||||
userLinkMode = "admin",
|
||||
}: {
|
||||
ticket: TicketInfo;
|
||||
messages: TicketMessage[];
|
||||
staffMembers: StaffMember[];
|
||||
currentUserId: number;
|
||||
canEdit: boolean;
|
||||
listHref?: string;
|
||||
userLinkMode?: "admin" | "public";
|
||||
}) {
|
||||
const [messages] = useState(initialMessages);
|
||||
const [reply, setReply] = useState("");
|
||||
const { run, isPending } = useServerAction();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function creatorHref() {
|
||||
if (userLinkMode === "public") {
|
||||
return `/u/${encodeURIComponent(ticket.creator.username)}`;
|
||||
}
|
||||
return `/admin/users/show/${ticket.creator.id}`;
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -152,7 +163,7 @@ export function AdminTicketDetail({
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<Link href="/admin/tickets">
|
||||
<Link href={listHref}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -382,7 +393,7 @@ export function AdminTicketDetail({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Link
|
||||
href={`/admin/users/show/${ticket.creator.id}`}
|
||||
href={creatorHref()}
|
||||
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
|
||||
>
|
||||
<User className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -29,7 +29,13 @@ const STATUS_VARIANT: Record<
|
||||
closed: "secondary",
|
||||
};
|
||||
|
||||
export function TicketsTable({ data }: { data: PaginatedResult<TicketRow> }) {
|
||||
export function TicketsTable({
|
||||
data,
|
||||
basePath = "/admin/tickets",
|
||||
}: {
|
||||
data: PaginatedResult<TicketRow>;
|
||||
basePath?: string;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.tickets");
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
@@ -51,7 +57,7 @@ export function TicketsTable({ data }: { data: PaginatedResult<TicketRow> }) {
|
||||
#{row.id}
|
||||
</span>
|
||||
<Link
|
||||
href={`/admin/tickets/${row.id}`}
|
||||
href={`${basePath}/${row.id}`}
|
||||
className="font-medium truncate hover:underline"
|
||||
>
|
||||
{row.subject}
|
||||
|
||||
+14
-1
@@ -1,4 +1,4 @@
|
||||
import { Ban, Gavel, LogOut, Radio, Shield } from "lucide-react";
|
||||
import { Ban, Gavel, LogOut, Radio, Shield, Ticket, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
@@ -42,11 +42,23 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||
const showBans =
|
||||
canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) ||
|
||||
canAccess(permissions, PERMS.BANS_VIEW, staff.rank);
|
||||
const showTickets =
|
||||
canAccess(permissions, PERMS.MOD_TICKETS_VIEW, staff.rank) ||
|
||||
canAccess(permissions, PERMS.TICKETS_VIEW, staff.rank);
|
||||
const showTeam =
|
||||
canAccess(permissions, PERMS.MOD_TEAM_VIEW, staff.rank) ||
|
||||
canAccess(permissions, PERMS.MODERATION_VIEW, staff.rank);
|
||||
const showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank);
|
||||
|
||||
const nav = [
|
||||
{ href: "/mod", label: t("nav.overview"), icon: Shield, show: true },
|
||||
{ href: "/mod/cfh", label: t("nav.cfh"), icon: Radio, show: showCfh },
|
||||
{
|
||||
href: "/mod/tickets",
|
||||
label: t("nav.tickets"),
|
||||
icon: Ticket,
|
||||
show: showTickets,
|
||||
},
|
||||
{
|
||||
href: "/mod/actions",
|
||||
label: t("nav.actions"),
|
||||
@@ -54,6 +66,7 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||
show: showActions,
|
||||
},
|
||||
{ href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans },
|
||||
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
|
||||
].filter((item) => item.show);
|
||||
|
||||
return (
|
||||
|
||||
+49
-3
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -70,7 +70,10 @@ export async function requireMod(): Promise<StaffUser> {
|
||||
canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank) ||
|
||||
canAccess(permissions, PERMS.MOD_CFH_VIEW, session.user.rank) ||
|
||||
canAccess(permissions, PERMS.MOD_ACTIONS, session.user.rank) ||
|
||||
canAccess(permissions, PERMS.MOD_BANS_VIEW, session.user.rank);
|
||||
canAccess(permissions, PERMS.MOD_BANS_VIEW, session.user.rank) ||
|
||||
canAccess(permissions, PERMS.MOD_TICKETS_VIEW, session.user.rank) ||
|
||||
canAccess(permissions, PERMS.MOD_TEAM_VIEW, session.user.rank) ||
|
||||
canAccess(permissions, PERMS.TICKETS_VIEW, session.user.rank);
|
||||
if (!ok) redirectSafe("/", "/");
|
||||
return {
|
||||
id: session.user.id,
|
||||
|
||||
+22
-3
@@ -2726,7 +2726,21 @@
|
||||
"importTitle": "Import permissions",
|
||||
"legacyBannerTitle": "This table is not live ACL",
|
||||
"legacyBannerBody": "Runtime access uses the ACL permissions system. Use Housekeeping only as a legacy reference.",
|
||||
"legacyBannerCta": "Open live Permissions"
|
||||
"legacyBannerCta": "Open live Permissions",
|
||||
"writesRetired": "Create/edit/import/presets on this table are disabled. Change live access under Permissions.",
|
||||
"tabCompare": "ACL compare",
|
||||
"tabExportOnly": "Export archive",
|
||||
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||
"compareAligned": "{count} aligned",
|
||||
"compareMissingAcl": "{count} missing in ACL",
|
||||
"compareMismatch": "{count} rank mismatch",
|
||||
"colAclPresent": "In ACL",
|
||||
"colAclMinRank": "ACL min rank",
|
||||
"colStatus": "Status",
|
||||
"statusAligned": "Aligned",
|
||||
"statusMissingAcl": "Missing in ACL",
|
||||
"statusMismatch": "Rank differs",
|
||||
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Rooms",
|
||||
@@ -3028,14 +3042,19 @@
|
||||
"actions": "Quick actions",
|
||||
"bans": "Bans",
|
||||
"fullAdmin": "Full admin",
|
||||
"backToSite": "Back to site"
|
||||
"backToSite": "Back to site",
|
||||
"tickets": "Tickets",
|
||||
"team": "Team"
|
||||
},
|
||||
"stats": {
|
||||
"openCfh": "Open CFH",
|
||||
"bansToday": "Bans today",
|
||||
"modsOnline": "Mods online",
|
||||
"quickActions": "Quick actions",
|
||||
"quickActionsHint": "Kick, mute, alert"
|
||||
"quickActionsHint": "Kick, mute, alert",
|
||||
"openTickets": "Open tickets",
|
||||
"team": "Mod team",
|
||||
"teamHint": "Online staff & workload"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-3
@@ -2573,7 +2573,21 @@
|
||||
"importTitle": "Import permissions",
|
||||
"legacyBannerTitle": "Questa tabella non e ACL live",
|
||||
"legacyBannerBody": "L'accesso runtime usa il sistema ACL. Usa Housekeeping solo come riferimento legacy.",
|
||||
"legacyBannerCta": "Apri Permessi live"
|
||||
"legacyBannerCta": "Apri Permessi live",
|
||||
"writesRetired": "Create/modifica/import/preset su questa tabella sono disabilitati. Cambia l'accesso live in Permessi.",
|
||||
"tabCompare": "Confronto ACL",
|
||||
"tabExportOnly": "Esporta archivio",
|
||||
"compareHint": "Confronta le righe legacy housekeeping con i grant ACL live rank_* (grado minimo con lo slug).",
|
||||
"compareAligned": "{count} allineati",
|
||||
"compareMissingAcl": "{count} mancanti in ACL",
|
||||
"compareMismatch": "{count} mismatch grado",
|
||||
"colAclPresent": "In ACL",
|
||||
"colAclMinRank": "Min grado ACL",
|
||||
"colStatus": "Stato",
|
||||
"statusAligned": "Allineato",
|
||||
"statusMissingAcl": "Manca in ACL",
|
||||
"statusMismatch": "Grado diverso",
|
||||
"exportOnlyHint": "Le scritture sono disabilitate. Puoi ancora esportare la tabella legacy per archivio."
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Stanze",
|
||||
@@ -3031,14 +3045,19 @@
|
||||
"actions": "Azioni rapide",
|
||||
"bans": "Ban",
|
||||
"fullAdmin": "Admin completo",
|
||||
"backToSite": "Torna al sito"
|
||||
"backToSite": "Torna al sito",
|
||||
"tickets": "Ticket",
|
||||
"team": "Team"
|
||||
},
|
||||
"stats": {
|
||||
"openCfh": "CFH aperti",
|
||||
"bansToday": "Ban oggi",
|
||||
"modsOnline": "Mod online",
|
||||
"quickActions": "Azioni rapide",
|
||||
"quickActionsHint": "Kick, mute, alert"
|
||||
"quickActionsHint": "Kick, mute, alert",
|
||||
"openTickets": "Ticket aperti",
|
||||
"team": "Team mod",
|
||||
"teamHint": "Staff online e carico"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-3
@@ -2548,7 +2548,21 @@
|
||||
"importTitle": "Permissies importeren",
|
||||
"legacyBannerTitle": "Deze tabel is geen live ACL",
|
||||
"legacyBannerBody": "Runtime-toegang gebruikt het ACL-systeem. Gebruik Housekeeping alleen als legacy-referentie.",
|
||||
"legacyBannerCta": "Open live Permissions"
|
||||
"legacyBannerCta": "Open live Permissions",
|
||||
"writesRetired": "Aanmaken/bewerken/import/presets op deze tabel zijn uitgeschakeld. Wijzig live toegang onder Permissions.",
|
||||
"tabCompare": "ACL vergelijking",
|
||||
"tabExportOnly": "Exporteer archief",
|
||||
"compareHint": "Vergelijkt legacy housekeeping min-rank rijen met live ACL rank_* grants (laagste rang met de slug).",
|
||||
"compareAligned": "{count} uitgelijnd",
|
||||
"compareMissingAcl": "{count} ontbreekt in ACL",
|
||||
"compareMismatch": "{count} rang mismatch",
|
||||
"colAclPresent": "In ACL",
|
||||
"colAclMinRank": "ACL min rang",
|
||||
"colStatus": "Status",
|
||||
"statusAligned": "Uitgelijnd",
|
||||
"statusMissingAcl": "Ontbreekt in ACL",
|
||||
"statusMismatch": "Rang verschilt",
|
||||
"exportOnlyHint": "Schrijven is uitgeschakeld. Je kunt de legacy-tabel nog exporteren voor archief."
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Kamers",
|
||||
@@ -3029,14 +3043,19 @@
|
||||
"actions": "Snelle acties",
|
||||
"bans": "Bans",
|
||||
"fullAdmin": "Volledige admin",
|
||||
"backToSite": "Terug naar site"
|
||||
"backToSite": "Terug naar site",
|
||||
"tickets": "Tickets",
|
||||
"team": "Team"
|
||||
},
|
||||
"stats": {
|
||||
"openCfh": "Open CFH",
|
||||
"bansToday": "Bans vandaag",
|
||||
"modsOnline": "Mods online",
|
||||
"quickActions": "Snelle acties",
|
||||
"quickActionsHint": "Kick, mute, alert"
|
||||
"quickActionsHint": "Kick, mute, alert",
|
||||
"openTickets": "Open tickets",
|
||||
"team": "Mod team",
|
||||
"teamHint": "Online staff & werklast"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user