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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user