feat(admin): add SUPPORT role, user search/filter, and moderation audit gaps

Gitea #10 follow-up, Phase 1:
- AdminRoleType gains SUPPORT (same access as MODERATOR for now, pending
  a permission-matrix pass to scope it down)
- listUsers gains search (Clerk query + local pet-name match), ban-status,
  and role filters
- ModerationLog now also captures ad create/update/delete and role
  grant/revoke, which previously left no audit trail
- new getModeratorThroughput query surfaces per-moderator action counts
  on the Moderators page
This commit is contained in:
admin committed 2026-08-12 08:38:25 +02:00
1 parent 48ce0396ca
commit 2d656bd779
4 files changed
+201 -36

No files matched your search

+12 -1
View File
@@ -20,6 +20,10 @@ export default function AdminModeratorsPage() {
const [revokeTarget, setRevokeTarget] = useState<string | null>(null);
const { data: mods = [], isLoading } = useQuery(trpc.admin.listModerators.queryOptions());
const { data: throughput = [] } = useQuery(
trpc.admin.getModeratorThroughput.queryOptions({ days: 30 })
);
const actionCountByModeratorId = new Map(throughput.map((t) => [t.moderatorId, t.actionCount]));
const invalidate = () =>
void qc.invalidateQueries({ queryKey: trpc.admin.listModerators.queryKey() });
@@ -79,13 +83,19 @@ export default function AdminModeratorsPage() {
className={
mod.role === AdminRoleType.SUPER_ADMIN
? "bg-orange-700 text-orange-100 text-[10px]"
: "bg-zinc-700 text-zinc-200 text-[10px]"
: mod.role === AdminRoleType.SUPPORT
? "bg-blue-700 text-blue-100 text-[10px]"
: "bg-zinc-700 text-zinc-200 text-[10px]"
}
>
{mod.role}
</Badge>
</div>
{pet && <p className="text-xs text-zinc-500 mt-0.5">Pet: {pet.name}</p>}
<p className="text-xs text-zinc-500 mt-0.5">
{actionCountByModeratorId.get(mod.ownerId) ?? 0} action
{actionCountByModeratorId.get(mod.ownerId) === 1 ? "" : "s"} (last 30 days)
</p>
<p className="text-xs text-zinc-600 mt-0.5">
Granted {new Date(mod.grantedAt).toLocaleDateString()}
{mod.grantedBy && ` by ${mod.grantedBy.slice(0, 12)}…`}
@@ -129,6 +139,7 @@ export default function AdminModeratorsPage() {
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectItem value={AdminRoleType.SUPPORT}>Support</SelectItem>
<SelectItem value={AdminRoleType.MODERATOR}>Moderator</SelectItem>
<SelectItem value={AdminRoleType.SUPER_ADMIN}>Super Admin</SelectItem>
</SelectContent>
+42 -2
View File
@@ -11,6 +11,11 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AdminRoleType } from "@prisma/client";
type BanStatusFilter = "all" | "banned" | "active";
type RoleFilter = "all" | "none" | AdminRoleType;
export default function AdminUsersPage() {
const trpc = useTRPC();
@@ -21,10 +26,13 @@ export default function AdminUsersPage() {
const [unbanTarget, setUnbanTarget] = useState<string | null>(null);
const [banReason, setBanReason] = useState("");
const [banExpiry, setBanExpiry] = useState("");
const [search, setSearch] = useState("");
const [banStatus, setBanStatus] = useState<BanStatusFilter>("all");
const [role, setRole] = useState<RoleFilter>("all");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery(
trpc.admin.listUsers.infiniteQueryOptions(
{ limit: 20 },
{ limit: 20, search: search || undefined, banStatus, role },
{ getNextPageParam: (p) => p.nextCursor, initialCursor: undefined }
)
);
@@ -73,7 +81,39 @@ export default function AdminUsersPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-zinc-100">Users</h1>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold text-zinc-100">Users</h1>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search name or email…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-64 bg-zinc-900 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<Select value={banStatus} onValueChange={(v) => setBanStatus(v as BanStatusFilter)}>
<SelectTrigger className="w-36 bg-zinc-900 border-zinc-700 text-zinc-100">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="banned">Banned</SelectItem>
<SelectItem value="active">Active</SelectItem>
</SelectContent>
</Select>
<Select value={role} onValueChange={(v) => setRole(v as RoleFilter)}>
<SelectTrigger className="w-36 bg-zinc-900 border-zinc-700 text-zinc-100">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectItem value="all">All roles</SelectItem>
<SelectItem value="none">No role</SelectItem>
<SelectItem value={AdminRoleType.SUPPORT}>Support</SelectItem>
<SelectItem value={AdminRoleType.MODERATOR}>Moderator</SelectItem>
<SelectItem value={AdminRoleType.SUPER_ADMIN}>Super Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
+142 -31
View File
@@ -288,10 +288,48 @@ export const adminRouter = router({
// ─── Users / bans ────────────────────────────────────────────────────────────
listUsers: protectedProcedure
.input(z.object({ cursor: z.string().nullish(), limit: z.number().min(1).max(50).default(20) }))
.input(
z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).max(50).default(20),
search: z.string().optional(),
banStatus: z.enum(["all", "banned", "active"]).default("all"),
role: z.union([z.literal("all"), z.literal("none"), z.nativeEnum(AdminRoleType)]).default("all"),
})
)
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const clerk = await clerkClient();
// Owner has no local name/email (Clerk owns that) — resolve matching
// owner IDs by unioning Clerk's own query search (name/email/username)
// with a local Pet.name match, since admins commonly search by pet
// name too. Clerk search is capped at 100 matches for now.
let searchedIds: string[] | null = null;
if (input.search) {
const [clerkMatches, petMatches] = await Promise.all([
clerk.users.getUserList({ query: input.search, limit: 100 }),
ctx.prisma.pet.findMany({
where: { name: { contains: input.search, mode: "insensitive" } },
select: { ownerId: true },
}),
]);
searchedIds = [
...new Set([...clerkMatches.data.map((u) => u.id), ...petMatches.map((p) => p.ownerId)]),
];
if (searchedIds.length === 0) {
return { users: [], nextCursor: null };
}
}
const owners = await ctx.prisma.owner.findMany({
where: {
...(searchedIds ? { id: { in: searchedIds } } : {}),
...(input.banStatus === "banned" ? { ban: { isNot: null } } : {}),
...(input.banStatus === "active" ? { ban: null } : {}),
...(input.role === "none" ? { adminRole: null } : {}),
...(input.role !== "all" && input.role !== "none" ? { adminRole: { role: input.role } } : {}),
},
include: {
pets: { select: { id: true, name: true, avatarKey: true }, take: 3 },
ban: true,
@@ -307,7 +345,6 @@ export const adminRouter = router({
// Batch-fetch Clerk profile data (email/name) — Owner only stores the
// Clerk userId locally, everything else lives in Clerk (Gitea #6).
const clerk = await clerkClient();
const clerkUsers = items.length > 0
? (await clerk.users.getUserList({ userId: items.map((o) => o.id), limit: items.length })).data
: [];
@@ -666,19 +703,34 @@ export const adminRouter = router({
)
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.advertisement.create({
data: {
title: input.title,
caption: input.caption ?? null,
imageKey: input.imageKey ?? null,
targetUrl: input.targetUrl || null,
isActive: input.isActive,
species: input.species,
breeds: input.breeds,
startDate: input.startDate ? new Date(input.startDate) : null,
endDate: input.endDate ? new Date(input.endDate) : null,
createdBy: ctx.userId,
},
// Interactive transaction (not the array form used elsewhere in this file)
// because the log entry needs the DB-generated ad id, which only exists
// after the create resolves.
return ctx.prisma.$transaction(async (tx) => {
const ad = await tx.advertisement.create({
data: {
title: input.title,
caption: input.caption ?? null,
imageKey: input.imageKey ?? null,
targetUrl: input.targetUrl || null,
isActive: input.isActive,
species: input.species,
breeds: input.breeds,
startDate: input.startDate ? new Date(input.startDate) : null,
endDate: input.endDate ? new Date(input.endDate) : null,
createdBy: ctx.userId,
},
});
await tx.moderationLog.create({
data: {
action: "CREATE_AD",
targetType: "AD",
targetId: ad.id,
reason: input.title,
moderatorId: ctx.userId,
},
});
return ad;
});
}),
@@ -700,22 +752,43 @@ export const adminRouter = router({
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const { id, targetUrl, startDate, endDate, ...rest } = input;
return ctx.prisma.advertisement.update({
where: { id },
data: {
...rest,
targetUrl: targetUrl === "" ? null : targetUrl,
startDate: startDate ? new Date(startDate) : startDate === null ? null : undefined,
endDate: endDate ? new Date(endDate) : endDate === null ? null : undefined,
},
});
const [ad] = await ctx.prisma.$transaction([
ctx.prisma.advertisement.update({
where: { id },
data: {
...rest,
targetUrl: targetUrl === "" ? null : targetUrl,
startDate: startDate ? new Date(startDate) : startDate === null ? null : undefined,
endDate: endDate ? new Date(endDate) : endDate === null ? null : undefined,
},
}),
ctx.prisma.moderationLog.create({
data: {
action: "UPDATE_AD",
targetType: "AD",
targetId: id,
moderatorId: ctx.userId,
},
}),
]);
return ad;
}),
deleteAd: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
await ctx.prisma.advertisement.delete({ where: { id: input.id } });
await ctx.prisma.$transaction([
ctx.prisma.advertisement.delete({ where: { id: input.id } }),
ctx.prisma.moderationLog.create({
data: {
action: "DELETE_AD",
targetType: "AD",
targetId: input.id,
moderatorId: ctx.userId,
},
}),
]);
return { success: true };
}),
@@ -768,18 +841,40 @@ export const adminRouter = router({
.input(z.object({ ownerId: z.string().min(1), role: z.nativeEnum(AdminRoleType) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, true);
return ctx.prisma.adminRole.upsert({
where: { ownerId: input.ownerId },
create: { ownerId: input.ownerId, role: input.role, grantedBy: ctx.userId },
update: { role: input.role, grantedBy: ctx.userId, grantedAt: new Date() },
});
const [role] = await ctx.prisma.$transaction([
ctx.prisma.adminRole.upsert({
where: { ownerId: input.ownerId },
create: { ownerId: input.ownerId, role: input.role, grantedBy: ctx.userId },
update: { role: input.role, grantedBy: ctx.userId, grantedAt: new Date() },
}),
ctx.prisma.moderationLog.create({
data: {
action: "GRANT_ROLE",
targetType: "USER",
targetId: input.ownerId,
reason: input.role,
moderatorId: ctx.userId,
},
}),
]);
return role;
}),
revokeRole: protectedProcedure
.input(z.object({ ownerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, true);
await ctx.prisma.adminRole.delete({ where: { ownerId: input.ownerId } });
await ctx.prisma.$transaction([
ctx.prisma.adminRole.delete({ where: { ownerId: input.ownerId } }),
ctx.prisma.moderationLog.create({
data: {
action: "REVOKE_ROLE",
targetType: "USER",
targetId: input.ownerId,
moderatorId: ctx.userId,
},
}),
]);
return { success: true };
}),
@@ -793,6 +888,22 @@ export const adminRouter = router({
});
}),
/** Action count per moderator over the trailing window — throughput for the Moderators page. */
getModeratorThroughput: protectedProcedure
.input(z.object({ days: z.number().min(1).max(365).default(30) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const since = new Date(Date.now() - input.days * 24 * 60 * 60 * 1000);
const grouped = await ctx.prisma.moderationLog.groupBy({
by: ["moderatorId"],
where: { createdAt: { gte: since } },
_count: true,
});
return grouped
.map((g) => ({ moderatorId: g.moderatorId, actionCount: g._count }))
.sort((a, b) => b.actionCount - a.actionCount);
}),
// ─── Invite codes ─────────────────────────────────────────────────────────────
listInviteCodes: protectedProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(100) }))