feat(admin): bulk role updates + mentor-onboarding email (§D.2-3)

user.bulkUpdateRoles({userIds, addRole?, removeRole?}) batches role
changes across up to 200 users with a SUPER_ADMIN self-demote guard.
When MENTOR is freshly added, fires sendMentorOnboardingEmail once per
user, gated by User.mentorOnboardingSentAt for idempotency. Audit log
entry per user changed.

UI: 'Add MENTOR role' button surfaces in the existing /admin/members
bulk-selection toolbar when ≥1 user is selected. Other roles
(OBSERVER / AWARD_MASTER) supported by the procedure but not yet wired
to UI; one button keeps the toolbar minimal until a clear need arises.

Tests cover happy path, idempotency on second call, removeRole semantics,
and the SUPER_ADMIN self-demote guard.

Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md
This commit is contained in:
Matt
2026-04-28 16:05:16 +02:00
parent 0c2b2d1f96
commit 432470083c
3 changed files with 257 additions and 2 deletions

View File

@@ -191,6 +191,20 @@ export function MembersContent() {
},
})
const bulkUpdateRoles = trpc.user.bulkUpdateRoles.useMutation({
onSuccess: (r) => {
const parts: string[] = []
if (r.updated > 0) parts.push(`Updated ${r.updated} user${r.updated === 1 ? '' : 's'}`)
if (r.alreadyHadRole > 0) parts.push(`${r.alreadyHadRole} already had role`)
toast.success(parts.join(' · ') || 'No changes')
setSelectedIds(new Set())
utils.user.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to update roles')
},
})
const selectableUsers = useMemo(
() => data?.users ?? [],
[data?.users]
@@ -321,9 +335,29 @@ export function MembersContent() {
<Card>
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
Selection persists across pages and filters.
{selectedIds.size > 0
? `${selectedIds.size} selected. Selection persists across pages and filters.`
: 'Selection persists across pages and filters.'}
</p>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<Button
variant="outline"
size="sm"
onClick={() =>
bulkUpdateRoles.mutate({
userIds: Array.from(selectedIds),
addRole: 'MENTOR',
})
}
disabled={bulkUpdateRoles.isPending}
>
{bulkUpdateRoles.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null}
Add MENTOR role
</Button>
)}
<Button
variant="outline"
size="sm"