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