feat(admin): guilds console + user sanctions timeline
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
1e77398a7c
commit
a384c9a8bb
10 files changed
+3861
-2991
No files matched your search
@@ -0,0 +1,48 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Disband a guild and clean related membership/forum rows. */
|
||||
export async function disbandGuild(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
const guild = await prisma.guilds.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, name: true, userId: true },
|
||||
});
|
||||
if (!guild) return;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const threads = await tx.guildsForumsThreads.findMany({
|
||||
where: { guildId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
const threadIds = threads.map((t) => t.id);
|
||||
if (threadIds.length > 0) {
|
||||
await tx.guildsForumsComments.deleteMany({
|
||||
where: { threadId: { in: threadIds } },
|
||||
});
|
||||
await tx.guildsForumsThreads.deleteMany({ where: { guildId: id } });
|
||||
}
|
||||
await tx.guildForumViews.deleteMany({ where: { guildId: id } });
|
||||
await tx.guildsMembers.deleteMany({ where: { guildId: id } });
|
||||
await tx.rooms.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
|
||||
await tx.items.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
|
||||
await tx.guilds.delete({ where: { id } });
|
||||
});
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "guild_disband",
|
||||
description: `Disbanded guild #${id} (${guild.name}), owner #${guild.userId}`,
|
||||
targetType: "guild",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/guilds");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
||||
import Link from "next/link";
|
||||
import type { SanctionEvent } from "../_lib/load-user-sanctions";
|
||||
|
||||
function fmt(ts: number): string {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
const KIND_CLASS: Record<SanctionEvent["kind"], string> = {
|
||||
ban: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)]",
|
||||
lift: "bg-[var(--admin-success-subtle)] text-[var(--admin-success)]",
|
||||
mute: "bg-[var(--admin-accent)]/20 text-[var(--admin-accent)]",
|
||||
unmute: "bg-[var(--admin-success-subtle)] text-[var(--admin-success)]",
|
||||
cfh: "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]",
|
||||
staff: "bg-[var(--admin-border)] text-[var(--admin-text-muted)]",
|
||||
status: "bg-[var(--admin-accent)]/25 text-[var(--admin-accent)]",
|
||||
};
|
||||
|
||||
export function UserSanctionTimeline({
|
||||
events,
|
||||
muteUntil,
|
||||
}: {
|
||||
events: SanctionEvent[];
|
||||
muteUntil: number;
|
||||
}) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const muted = muteUntil > now;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold m-0">Sanctions timeline</h3>
|
||||
{muted ? (
|
||||
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/20 text-[var(--admin-accent)]">
|
||||
Muted until {fmt(muteUntil)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground m-0">
|
||||
No bans, mutes, CFH, or staff actions recorded for this user.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-3 m-0 p-0 list-none">
|
||||
{events.map((ev) => (
|
||||
<li
|
||||
key={ev.id}
|
||||
className="flex gap-3 border-b border-[var(--admin-border)] pb-3 last:border-0 last:pb-0"
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 mt-0.5 text-[0.65rem] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full ${KIND_CLASS[ev.kind]}`}
|
||||
>
|
||||
{ev.kind}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
{ev.href ? (
|
||||
<Link
|
||||
href={ev.href}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{ev.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-medium">{ev.label}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fmt(ev.at)}
|
||||
</span>
|
||||
{ev.actor ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
by {ev.actor}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground m-0 mt-0.5 break-words">
|
||||
{ev.detail}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,9 @@ import { getWatchedUserIds } from "@/lib/services/watch";
|
||||
import { UserAccountInfo } from "../../_components/user-account-info";
|
||||
import { UserBadgesReadonly } from "../../_components/user-badges-readonly";
|
||||
import { UserHero } from "../../_components/user-hero";
|
||||
import { UserSanctionTimeline } from "../../_components/user-sanction-timeline";
|
||||
import { loadUserById } from "../../_lib/load-user";
|
||||
import { loadUserSanctions } from "../../_lib/load-user-sanctions";
|
||||
import { UserActivityLog } from "../../[id]/user-activity-log";
|
||||
import { UserQuickActions } from "../../[id]/user-quick-actions";
|
||||
import { UserRooms } from "../../[id]/user-rooms";
|
||||
@@ -23,6 +25,7 @@ export default async function UserShowPage({
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
|
||||
const data = await loadUserById(id);
|
||||
const sanctions = await loadUserSanctions(data.user.id);
|
||||
const watched = await getWatchedUserIds(session.user.id);
|
||||
|
||||
return (
|
||||
@@ -55,6 +58,9 @@ export default async function UserShowPage({
|
||||
Badges ({data.user.badges.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
|
||||
<TabsTrigger value="sanctions">
|
||||
Sanctions ({sanctions.events.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -75,6 +81,13 @@ export default async function UserShowPage({
|
||||
<UserRooms rooms={data.userRooms} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sanctions">
|
||||
<UserSanctionTimeline
|
||||
events={sanctions.events}
|
||||
muteUntil={sanctions.muteUntil}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<UserActivityLog
|
||||
chatLogs={data.chatLogs}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Ticket,
|
||||
Trophy,
|
||||
Users,
|
||||
UsersRound,
|
||||
Volume2,
|
||||
Vote,
|
||||
Wifi,
|
||||
@@ -329,6 +330,13 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
permission: PERMS.POLLS_VIEW,
|
||||
matchPrefixes: ["/admin/polls"],
|
||||
},
|
||||
{
|
||||
href: "/admin/guilds",
|
||||
labelKey: "guilds",
|
||||
icon: UsersRound,
|
||||
permission: PERMS.USERS_VIEW,
|
||||
matchPrefixes: ["/admin/guilds"],
|
||||
},
|
||||
{
|
||||
href: "/admin/tickets",
|
||||
labelKey: "tickets",
|
||||
|
||||
@@ -1022,6 +1022,7 @@
|
||||
"helpTickets": "Help-center tickets",
|
||||
"events": "Events",
|
||||
"polls": "Polls",
|
||||
"guilds": "Guilds",
|
||||
"banners": "Banners",
|
||||
"prefixes": "Prefixes",
|
||||
"usersAndAccess": "Users & access",
|
||||
@@ -1815,6 +1816,31 @@
|
||||
"colSold": "Sold at",
|
||||
"colActions": "Actions"
|
||||
},
|
||||
"guilds": {
|
||||
"title": "Guilds",
|
||||
"subtitle": "Hotel groups and membership",
|
||||
"totalGuilds": "Guilds",
|
||||
"searchPlaceholder": "Search by name, description or ID…",
|
||||
"search": "Search",
|
||||
"empty": "No guilds found.",
|
||||
"loadError": "Could not load guilds (database unavailable).",
|
||||
"colId": "ID",
|
||||
"colBadge": "Badge",
|
||||
"colName": "Name",
|
||||
"colOwner": "Owner",
|
||||
"colMembers": "Members",
|
||||
"colCreated": "Created",
|
||||
"colThreads": "Forum threads",
|
||||
"colRoom": "Room",
|
||||
"colUser": "User",
|
||||
"colLevel": "Level",
|
||||
"colJoined": "Joined",
|
||||
"members": "Members",
|
||||
"noMembers": "No members.",
|
||||
"back": "Back to guilds",
|
||||
"viewPublic": "Open public page",
|
||||
"disband": "Disband guild"
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Transactions",
|
||||
"subtitle": "PayPal payment log",
|
||||
|
||||
+3017
-2991
File diff suppressed because it is too large.
Load diff
@@ -1019,6 +1019,7 @@
|
||||
"helpTickets": "Helpcentrum-tickets",
|
||||
"events": "Events",
|
||||
"polls": "Polls",
|
||||
"guilds": "Gildes",
|
||||
"banners": "Banners",
|
||||
"prefixes": "Prefixes",
|
||||
"usersAndAccess": "Gebruikers & toegang",
|
||||
@@ -1747,6 +1748,31 @@
|
||||
"colSold": "Verkocht op",
|
||||
"colActions": "Acties"
|
||||
},
|
||||
"guilds": {
|
||||
"title": "Gildes",
|
||||
"subtitle": "Hotelgroepen en leden",
|
||||
"totalGuilds": "Gildes",
|
||||
"searchPlaceholder": "Zoek op naam, beschrijving of ID…",
|
||||
"search": "Zoeken",
|
||||
"empty": "Geen gildes gevonden.",
|
||||
"loadError": "Kon gildes niet laden (database niet beschikbaar).",
|
||||
"colId": "ID",
|
||||
"colBadge": "Badge",
|
||||
"colName": "Naam",
|
||||
"colOwner": "Eigenaar",
|
||||
"colMembers": "Leden",
|
||||
"colCreated": "Aangemaakt",
|
||||
"colThreads": "Forumthreads",
|
||||
"colRoom": "Kamer",
|
||||
"colUser": "Gebruiker",
|
||||
"colLevel": "Niveau",
|
||||
"colJoined": "Lid sinds",
|
||||
"members": "Leden",
|
||||
"noMembers": "Geen leden.",
|
||||
"back": "Terug naar gildes",
|
||||
"viewPublic": "Openbare pagina",
|
||||
"disband": "Gilde ontbinden"
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Transacties",
|
||||
"subtitle": "PayPal betalingslogboek",
|
||||
|
||||
Reference in new issue
Block a user