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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { disbandGuild } from "@/actions/admin-guilds";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function fmt(ts: number): string {
|
||||||
|
if (!ts) return "—";
|
||||||
|
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminGuildDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
|
||||||
|
const t = await getTranslations("pages.admin.guilds");
|
||||||
|
const { id: rawId } = await params;
|
||||||
|
const id = Number(rawId);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) notFound();
|
||||||
|
|
||||||
|
const guild = await prisma.guilds.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
userId: true,
|
||||||
|
roomId: true,
|
||||||
|
badge: true,
|
||||||
|
dateCreated: true,
|
||||||
|
state: true,
|
||||||
|
forum: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!guild) notFound();
|
||||||
|
|
||||||
|
const [owner, members, memberCount, threadCount] = await Promise.all([
|
||||||
|
prisma.user
|
||||||
|
.findUnique({
|
||||||
|
where: { id: guild.userId },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
})
|
||||||
|
.catch(() => null),
|
||||||
|
prisma.guildsMembers
|
||||||
|
.findMany({
|
||||||
|
where: { guildId: id },
|
||||||
|
orderBy: [{ levelId: "asc" }, { memberSince: "asc" }],
|
||||||
|
take: 100,
|
||||||
|
select: { userId: true, levelId: true, memberSince: true },
|
||||||
|
})
|
||||||
|
.catch(() => []),
|
||||||
|
prisma.guildsMembers.count({ where: { guildId: id } }).catch(() => 0),
|
||||||
|
prisma.guildsForumsThreads.count({ where: { guildId: id } }).catch(() => 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const memberUserIds = members.map((m) => m.userId).filter((uid) => uid > 0);
|
||||||
|
const memberUsers =
|
||||||
|
memberUserIds.length > 0
|
||||||
|
? await prisma.user
|
||||||
|
.findMany({
|
||||||
|
where: { id: { in: memberUserIds } },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
})
|
||||||
|
.catch(() => [])
|
||||||
|
: [];
|
||||||
|
const usernameById = new Map(memberUsers.map((u) => [u.id, u.username]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{guild.badge ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
|
||||||
|
alt=""
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="h-16 w-16 object-contain"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<h1 className="m-0 text-xl font-bold">
|
||||||
|
{guild.name || `#${guild.id}`}
|
||||||
|
</h1>
|
||||||
|
<p className="muted m-0 text-sm">#{guild.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link href="/admin/guilds" className="btn btn-ghost btn-sm">
|
||||||
|
{t("back")}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/guilds/${guild.id}`}
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{t("viewPublic")}
|
||||||
|
</Link>
|
||||||
|
{canEdit ? (
|
||||||
|
<form action={disbandGuild}>
|
||||||
|
<input type="hidden" name="id" value={guild.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-sm"
|
||||||
|
style={{ color: "var(--admin-error)" }}
|
||||||
|
>
|
||||||
|
{t("disband")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{guild.description ? (
|
||||||
|
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||||
|
{guild.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="text-xs muted">{t("colOwner")}</div>
|
||||||
|
<div>
|
||||||
|
{owner ? (
|
||||||
|
<Link href={`/admin/users/show/${owner.id}`}>
|
||||||
|
{owner.username}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
`#${guild.userId}`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="text-xs muted">{t("colMembers")}</div>
|
||||||
|
<div className="text-lg font-bold">{memberCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="text-xs muted">{t("colThreads")}</div>
|
||||||
|
<div className="text-lg font-bold">{threadCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="text-xs muted">{t("colCreated")}</div>
|
||||||
|
<div>{fmt(guild.dateCreated)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="text-xs muted">{t("colRoom")}</div>
|
||||||
|
<div>
|
||||||
|
{guild.roomId > 0 ? (
|
||||||
|
<Link href={`/admin/rooms/show/${guild.roomId}`}>
|
||||||
|
#{guild.roomId}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="admin-section-title">
|
||||||
|
{t("members")} ({Math.min(members.length, memberCount)})
|
||||||
|
</h2>
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<div className="admin-empty">{t("noMembers")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("colUser")}</th>
|
||||||
|
<th>{t("colLevel")}</th>
|
||||||
|
<th>{t("colJoined")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map((m) => (
|
||||||
|
<tr key={`${m.userId}-${m.memberSince}`}>
|
||||||
|
<td>
|
||||||
|
<Link href={`/admin/users/show/${m.userId}`}>
|
||||||
|
{usernameById.get(m.userId) ?? `#${m.userId}`}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="muted">{m.levelId}</td>
|
||||||
|
<td className="muted">{fmt(m.memberSince)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
||||||
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function fmt(ts: number): string {
|
||||||
|
if (!ts) return "—";
|
||||||
|
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminGuildsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
}) {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations("pages.admin.guilds");
|
||||||
|
const raw = await searchParams;
|
||||||
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
|
const q = parsed.search.trim();
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let rows: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
userId: number;
|
||||||
|
badge: string;
|
||||||
|
dateCreated: number;
|
||||||
|
roomId: number;
|
||||||
|
}> = [];
|
||||||
|
let loadError = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const where = q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q } },
|
||||||
|
{ description: { contains: q } },
|
||||||
|
...(Number.isFinite(Number(q)) && Number(q) > 0
|
||||||
|
? [{ id: Number(q) }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
total = await prisma.guilds.count({ where });
|
||||||
|
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||||
|
rows = await prisma.guilds.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
userId: true,
|
||||||
|
badge: true,
|
||||||
|
dateCreated: true,
|
||||||
|
roomId: true,
|
||||||
|
},
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
skip: pagination.offset,
|
||||||
|
take: pagination.perPage,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
loadError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||||
|
const ownerIds = [
|
||||||
|
...new Set(rows.map((r) => r.userId).filter((id) => id > 0)),
|
||||||
|
];
|
||||||
|
const guildIds = rows.map((r) => r.id);
|
||||||
|
|
||||||
|
const [owners, memberGroups] = await Promise.all([
|
||||||
|
ownerIds.length
|
||||||
|
? prisma.user
|
||||||
|
.findMany({
|
||||||
|
where: { id: { in: ownerIds } },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
})
|
||||||
|
.catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
|
guildIds.length
|
||||||
|
? prisma.guildsMembers
|
||||||
|
.groupBy({
|
||||||
|
by: ["guildId"],
|
||||||
|
where: { guildId: { in: guildIds } },
|
||||||
|
_count: { _all: true },
|
||||||
|
})
|
||||||
|
.catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ownerById = new Map(owners.map((u) => [u.id, u.username]));
|
||||||
|
const membersByGuild = new Map(
|
||||||
|
memberGroups.map((g) => [g.guildId, g._count._all]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
{loadError ? (
|
||||||
|
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
||||||
|
{t("loadError")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="mb-4 flex gap-2" method="get">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="search"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder={t("searchPlaceholder")}
|
||||||
|
className="admin-input flex-1"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary btn-sm">
|
||||||
|
{t("search")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
|
<StatusCard label={t("totalGuilds")} value={total} icon="🛡️" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="admin-section-title">
|
||||||
|
{t("title")} ({total})
|
||||||
|
</h2>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="admin-empty">{t("empty")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("colId")}</th>
|
||||||
|
<th>{t("colBadge")}</th>
|
||||||
|
<th>{t("colName")}</th>
|
||||||
|
<th>{t("colOwner")}</th>
|
||||||
|
<th>{t("colMembers")}</th>
|
||||||
|
<th>{t("colCreated")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((g) => (
|
||||||
|
<tr key={g.id}>
|
||||||
|
<td className="muted">{g.id}</td>
|
||||||
|
<td>
|
||||||
|
{g.badge ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||||
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="h-10 w-10 object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
href={`/admin/guilds/${g.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{g.name || `#${g.id}`}
|
||||||
|
</Link>
|
||||||
|
{g.description ? (
|
||||||
|
<div className="muted text-xs line-clamp-1 max-w-xs">
|
||||||
|
{g.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/admin/users/show/${g.userId}`}>
|
||||||
|
{ownerById.get(g.userId) ?? `#${g.userId}`}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td>{membersByGuild.get(g.id) ?? 0}</td>
|
||||||
|
<td className="muted">{fmt(g.dateCreated)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AdminSimplePager
|
||||||
|
page={pagination.page}
|
||||||
|
lastPage={pagination.lastPage}
|
||||||
|
total={total}
|
||||||
|
basePath="/admin/guilds"
|
||||||
|
params={q ? { search: q } : undefined}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { UserAccountInfo } from "../../_components/user-account-info";
|
||||||
import { UserBadgesReadonly } from "../../_components/user-badges-readonly";
|
import { UserBadgesReadonly } from "../../_components/user-badges-readonly";
|
||||||
import { UserHero } from "../../_components/user-hero";
|
import { UserHero } from "../../_components/user-hero";
|
||||||
|
import { UserSanctionTimeline } from "../../_components/user-sanction-timeline";
|
||||||
import { loadUserById } from "../../_lib/load-user";
|
import { loadUserById } from "../../_lib/load-user";
|
||||||
|
import { loadUserSanctions } from "../../_lib/load-user-sanctions";
|
||||||
import { UserActivityLog } from "../../[id]/user-activity-log";
|
import { UserActivityLog } from "../../[id]/user-activity-log";
|
||||||
import { UserQuickActions } from "../../[id]/user-quick-actions";
|
import { UserQuickActions } from "../../[id]/user-quick-actions";
|
||||||
import { UserRooms } from "../../[id]/user-rooms";
|
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 canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
|
||||||
const data = await loadUserById(id);
|
const data = await loadUserById(id);
|
||||||
|
const sanctions = await loadUserSanctions(data.user.id);
|
||||||
const watched = await getWatchedUserIds(session.user.id);
|
const watched = await getWatchedUserIds(session.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,6 +58,9 @@ export default async function UserShowPage({
|
|||||||
Badges ({data.user.badges.length})
|
Badges ({data.user.badges.length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
|
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
|
||||||
|
<TabsTrigger value="sanctions">
|
||||||
|
Sanctions ({sanctions.events.length})
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -75,6 +81,13 @@ export default async function UserShowPage({
|
|||||||
<UserRooms rooms={data.userRooms} />
|
<UserRooms rooms={data.userRooms} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sanctions">
|
||||||
|
<UserSanctionTimeline
|
||||||
|
events={sanctions.events}
|
||||||
|
muteUntil={sanctions.muteUntil}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="activity">
|
<TabsContent value="activity">
|
||||||
<UserActivityLog
|
<UserActivityLog
|
||||||
chatLogs={data.chatLogs}
|
chatLogs={data.chatLogs}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
Ticket,
|
Ticket,
|
||||||
Trophy,
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
|
UsersRound,
|
||||||
Volume2,
|
Volume2,
|
||||||
Vote,
|
Vote,
|
||||||
Wifi,
|
Wifi,
|
||||||
@@ -329,6 +330,13 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
permission: PERMS.POLLS_VIEW,
|
permission: PERMS.POLLS_VIEW,
|
||||||
matchPrefixes: ["/admin/polls"],
|
matchPrefixes: ["/admin/polls"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/guilds",
|
||||||
|
labelKey: "guilds",
|
||||||
|
icon: UsersRound,
|
||||||
|
permission: PERMS.USERS_VIEW,
|
||||||
|
matchPrefixes: ["/admin/guilds"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/tickets",
|
href: "/admin/tickets",
|
||||||
labelKey: "tickets",
|
labelKey: "tickets",
|
||||||
|
|||||||
@@ -1022,6 +1022,7 @@
|
|||||||
"helpTickets": "Help-center tickets",
|
"helpTickets": "Help-center tickets",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls",
|
"polls": "Polls",
|
||||||
|
"guilds": "Guilds",
|
||||||
"banners": "Banners",
|
"banners": "Banners",
|
||||||
"prefixes": "Prefixes",
|
"prefixes": "Prefixes",
|
||||||
"usersAndAccess": "Users & access",
|
"usersAndAccess": "Users & access",
|
||||||
@@ -1815,6 +1816,31 @@
|
|||||||
"colSold": "Sold at",
|
"colSold": "Sold at",
|
||||||
"colActions": "Actions"
|
"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": {
|
"transactions": {
|
||||||
"title": "Transactions",
|
"title": "Transactions",
|
||||||
"subtitle": "PayPal payment log",
|
"subtitle": "PayPal payment log",
|
||||||
|
|||||||
+3017
-2991
File diff suppressed because it is too large.
Load diff
@@ -1019,6 +1019,7 @@
|
|||||||
"helpTickets": "Helpcentrum-tickets",
|
"helpTickets": "Helpcentrum-tickets",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls",
|
"polls": "Polls",
|
||||||
|
"guilds": "Gildes",
|
||||||
"banners": "Banners",
|
"banners": "Banners",
|
||||||
"prefixes": "Prefixes",
|
"prefixes": "Prefixes",
|
||||||
"usersAndAccess": "Gebruikers & toegang",
|
"usersAndAccess": "Gebruikers & toegang",
|
||||||
@@ -1747,6 +1748,31 @@
|
|||||||
"colSold": "Verkocht op",
|
"colSold": "Verkocht op",
|
||||||
"colActions": "Acties"
|
"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": {
|
"transactions": {
|
||||||
"title": "Transacties",
|
"title": "Transacties",
|
||||||
"subtitle": "PayPal betalingslogboek",
|
"subtitle": "PayPal betalingslogboek",
|
||||||
|
|||||||
Reference in new issue
Block a user