feat: impersonation system, semi-finalist detail page, tRPC resilience
- Add super-admin impersonation: "Login As" from user list, red banner with "Return to Admin", audit logged start/end, nested impersonation blocked, onboarding gate skipped during impersonation - Fix semi-finalist stats: check latest terminal state (not any PASSED), use passwordHash OR status=ACTIVE for activation check - Add /admin/semi-finalists detail page with search, category/status filters - Add account_reminder_days setting to notifications tab - Add tRPC resilience: retry on 503/HTML responses, custom fetch detects nginx error pages, exponential backoff (2s/4s/8s) - Reduce dashboard polling intervals (60s stats, 30s activity, 120s semi) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1689,4 +1689,86 @@ export const userRouter = router({
|
||||
|
||||
return { sent, skipped, failed }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Start impersonating a user (super admin only)
|
||||
*/
|
||||
startImpersonation: superAdminProcedure
|
||||
.input(z.object({ targetUserId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Block nested impersonation
|
||||
if ((ctx.session as unknown as { user?: { impersonating?: unknown } })?.user?.impersonating) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Cannot start nested impersonation. End current impersonation first.',
|
||||
})
|
||||
}
|
||||
|
||||
const target = await ctx.prisma.user.findUnique({
|
||||
where: { id: input.targetUserId },
|
||||
select: { id: true, email: true, name: true, role: true, roles: true, status: true },
|
||||
})
|
||||
|
||||
if (!target) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' })
|
||||
}
|
||||
|
||||
if (target.status === 'SUSPENDED') {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot impersonate a suspended user' })
|
||||
}
|
||||
|
||||
if (target.role === 'SUPER_ADMIN') {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot impersonate another super admin' })
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'IMPERSONATION_START',
|
||||
entityType: 'User',
|
||||
entityId: target.id,
|
||||
detailsJson: {
|
||||
adminId: ctx.user.id,
|
||||
adminEmail: ctx.user.email,
|
||||
targetId: target.id,
|
||||
targetEmail: target.email,
|
||||
targetRole: target.role,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { targetUserId: target.id, targetRole: target.role }
|
||||
}),
|
||||
|
||||
/**
|
||||
* End impersonation and return to admin
|
||||
*/
|
||||
endImpersonation: protectedProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
const session = ctx.session as unknown as { user?: { impersonating?: { originalId: string; originalEmail: string } } }
|
||||
const impersonating = session?.user?.impersonating
|
||||
|
||||
if (!impersonating) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not currently impersonating' })
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: impersonating.originalId,
|
||||
action: 'IMPERSONATION_END',
|
||||
entityType: 'User',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: {
|
||||
adminId: impersonating.originalId,
|
||||
adminEmail: impersonating.originalEmail,
|
||||
targetId: ctx.user.id,
|
||||
targetEmail: ctx.user.email,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { ended: true }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user