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,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { isBanActive, unixNow } from "@/lib/bans";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type SanctionEvent = {
|
||||
id: string;
|
||||
at: number;
|
||||
kind: "ban" | "lift" | "mute" | "unmute" | "cfh" | "staff" | "status";
|
||||
label: string;
|
||||
detail: string;
|
||||
actor?: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
function parseAuditAt(createdAt: string): number {
|
||||
const asNum = Number(createdAt);
|
||||
if (Number.isFinite(asNum) && asNum > 1_000_000_000) return asNum;
|
||||
const ms = Date.parse(createdAt);
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
||||
}
|
||||
|
||||
const AUDIT_ACTIONS = [
|
||||
"ban",
|
||||
"unban",
|
||||
"user_mute",
|
||||
"user_unmute",
|
||||
"mod_mute",
|
||||
"mod_unmute",
|
||||
"user_ban",
|
||||
"ban_lift",
|
||||
] as const;
|
||||
|
||||
/** Build a merged sanction timeline for an admin user show tab. */
|
||||
export async function loadUserSanctions(
|
||||
userId: number,
|
||||
): Promise<{ events: SanctionEvent[]; muteUntil: number }> {
|
||||
const now = unixNow();
|
||||
const events: SanctionEvent[] = [];
|
||||
|
||||
const [bans, audits, staffActs, cfh, settings] = await Promise.all([
|
||||
prisma.ban
|
||||
.findMany({
|
||||
where: { userId },
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: 40,
|
||||
select: {
|
||||
id: true,
|
||||
banReason: true,
|
||||
type: true,
|
||||
timestamp: true,
|
||||
banExpire: true,
|
||||
userStaffId: true,
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
prisma.adminAuditLog
|
||||
.findMany({
|
||||
where: {
|
||||
target: "User",
|
||||
targetId: userId,
|
||||
action: { in: [...AUDIT_ACTIONS] },
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
take: 40,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
details: true,
|
||||
createdAt: true,
|
||||
userId: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
prisma.staffActivities
|
||||
.findMany({
|
||||
where: {
|
||||
targetType: "user",
|
||||
targetId: BigInt(userId),
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
take: 40,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
userId: true,
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
prisma.supportTickets
|
||||
.findMany({
|
||||
where: {
|
||||
OR: [{ reportedId: userId }, { senderId: userId }],
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
issue: true,
|
||||
state: true,
|
||||
timestamp: true,
|
||||
senderId: true,
|
||||
reportedId: true,
|
||||
modId: true,
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
prisma.usersSettings
|
||||
.findUnique({
|
||||
where: { userId },
|
||||
select: { muteEndTimestamp: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const muteUntil = settings?.muteEndTimestamp ?? 0;
|
||||
if (muteUntil > now) {
|
||||
events.push({
|
||||
id: "mute-active",
|
||||
at: now,
|
||||
kind: "status",
|
||||
label: "Mute active",
|
||||
detail: `Until ${new Date(muteUntil * 1000).toISOString().slice(0, 16).replace("T", " ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const staffIds = [
|
||||
...new Set([
|
||||
...bans.map((b) => b.userStaffId).filter((id) => id > 0),
|
||||
...staffActs.map((a) => Number(a.userId)).filter((id) => id > 0),
|
||||
]),
|
||||
];
|
||||
const staffUsers =
|
||||
staffIds.length > 0
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: staffIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const staffName = new Map(staffUsers.map((u) => [u.id, u.username]));
|
||||
|
||||
for (const ban of bans) {
|
||||
const active = isBanActive(ban.banExpire, now);
|
||||
events.push({
|
||||
id: `ban-${ban.id}`,
|
||||
at: ban.timestamp,
|
||||
kind: "ban",
|
||||
label: active ? `Ban (${ban.type}) · active` : `Ban (${ban.type})`,
|
||||
detail: ban.banReason || "—",
|
||||
actor: staffName.get(ban.userStaffId),
|
||||
});
|
||||
}
|
||||
|
||||
for (const a of audits) {
|
||||
const kind: SanctionEvent["kind"] =
|
||||
a.action.includes("unmute") || a.action === "unban"
|
||||
? a.action.includes("mute")
|
||||
? "unmute"
|
||||
: "lift"
|
||||
: a.action.includes("mute")
|
||||
? "mute"
|
||||
: a.action.includes("ban")
|
||||
? "ban"
|
||||
: "staff";
|
||||
events.push({
|
||||
id: `audit-${a.id}`,
|
||||
at: parseAuditAt(a.createdAt),
|
||||
kind,
|
||||
label: a.action,
|
||||
detail: a.details?.slice(0, 200) || "—",
|
||||
actor: a.user?.username,
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of staffActs) {
|
||||
events.push({
|
||||
id: `staff-${String(s.id)}`,
|
||||
at: Math.floor(s.createdAt.getTime() / 1000),
|
||||
kind: "staff",
|
||||
label: s.action,
|
||||
detail: s.description.slice(0, 200),
|
||||
actor: staffName.get(Number(s.userId)),
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of cfh) {
|
||||
const role =
|
||||
c.reportedId === userId
|
||||
? "reported"
|
||||
: c.senderId === userId
|
||||
? "reporter"
|
||||
: "related";
|
||||
events.push({
|
||||
id: `cfh-${c.id}`,
|
||||
at: c.timestamp,
|
||||
kind: "cfh",
|
||||
label: `CFH #${c.id} (${role}, state ${c.state})`,
|
||||
detail: c.issue?.slice(0, 200) || "—",
|
||||
href: `/admin/moderation/cfh/${c.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
events.sort((a, b) => b.at - a.at || a.id.localeCompare(b.id));
|
||||
|
||||
// Deduplicate near-identical ban+audit pairs within 5s
|
||||
const deduped: SanctionEvent[] = [];
|
||||
for (const ev of events) {
|
||||
const prev = deduped[deduped.length - 1];
|
||||
if (
|
||||
prev &&
|
||||
prev.kind === ev.kind &&
|
||||
Math.abs(prev.at - ev.at) <= 5 &&
|
||||
prev.label.split(" ")[0] === ev.label.split(" ")[0]
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
deduped.push(ev);
|
||||
}
|
||||
|
||||
return { events: deduped.slice(0, 80), muteUntil };
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user