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:
1 parent
48ce0396ca
commit
2d656bd779
4 files changed
+201
-36
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) }))
|
||||
|
||||
Reference in new issue
Block a user