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:
2026-03-04 17:55:44 +01:00
parent b1a994a9d6
commit 6c52e519e5
18 changed files with 814 additions and 74 deletions

View File

@@ -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 }
}),
})