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"

View File

@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
import { UserRole } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
import { sendInvitationEmail, sendJuryInvitationEmail, sendMagicLinkEmail, sendPasswordResetEmail } from '@/lib/email'
import { sendInvitationEmail, sendJuryInvitationEmail, sendMagicLinkEmail, sendMentorOnboardingEmail, sendPasswordResetEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit'
@@ -1819,6 +1819,126 @@ export const userRouter = router({
})
}),
/**
* Bulk add/remove a single role across multiple users. Fires the mentor
* onboarding email exactly once per user when MENTOR is freshly added,
* idempotent via User.mentorOnboardingSentAt.
*/
bulkUpdateRoles: adminProcedure
.input(
z.object({
userIds: z.array(z.string()).min(1).max(200),
addRole: z.nativeEnum(UserRole).optional(),
removeRole: z.nativeEnum(UserRole).optional(),
}).refine((d) => d.addRole || d.removeRole, {
message: 'Provide addRole or removeRole',
}),
)
.mutation(async ({ ctx, input }) => {
// Self-demote guard
if (input.removeRole === 'SUPER_ADMIN' && input.userIds.includes(ctx.user.id)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You cannot remove SUPER_ADMIN from self',
})
}
// Privilege guard: only SUPER_ADMIN can grant SUPER_ADMIN
if (input.addRole === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can grant super admin role',
})
}
const targets = await ctx.prisma.user.findMany({
where: { id: { in: input.userIds } },
select: { id: true, name: true, email: true, roles: true, mentorOnboardingSentAt: true },
})
let updated = 0
let alreadyHadRole = 0
const newlyMentor: typeof targets = []
const rolePriority: UserRole[] = [
'SUPER_ADMIN',
'PROGRAM_ADMIN',
'JURY_MEMBER',
'MENTOR',
'OBSERVER',
'AWARD_MASTER',
'APPLICANT',
'AUDIENCE',
]
for (const u of targets) {
const current = new Set(u.roles)
const next = new Set(current)
if (input.addRole) {
if (current.has(input.addRole)) {
alreadyHadRole++
continue
}
next.add(input.addRole)
}
if (input.removeRole) {
if (!current.has(input.removeRole)) {
alreadyHadRole++
continue
}
next.delete(input.removeRole)
}
if (next.size === 0) next.add('APPLICANT' as UserRole) // safety: never empty
const nextArr = Array.from(next)
const primary = rolePriority.find((r) => nextArr.includes(r)) ?? nextArr[0]
const isFreshMentor =
input.addRole === 'MENTOR' && !current.has('MENTOR') && !u.mentorOnboardingSentAt
await ctx.prisma.user.update({
where: { id: u.id },
data: {
roles: nextArr,
role: primary,
...(isFreshMentor ? { mentorOnboardingSentAt: new Date() } : {}),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: input.addRole ? 'USER_ROLE_ADD' : 'USER_ROLE_REMOVE',
entityType: 'User',
entityId: u.id,
detailsJson: {
addRole: input.addRole,
removeRole: input.removeRole,
before: u.roles,
after: nextArr,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
if (isFreshMentor) newlyMentor.push(u)
updated++
}
// Send onboarding emails outside the per-user mutation loop. Errors
// caught and logged — we don't roll back the role grant if email
// delivery fails.
for (const u of newlyMentor) {
try {
await sendMentorOnboardingEmail(u.email, u.name)
} catch (err) {
console.error('[bulkUpdateRoles] mentor-onboarding email failed for', u.email, err)
}
}
return { updated, alreadyHadRole, total: targets.length }
}),
/**
* List applicant users with project info for admin bulk-invite page.
*/