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

@@ -221,7 +221,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
],
callbacks: {
...authConfig.callbacks,
async jwt({ token, user, trigger }) {
async jwt({ token, user, trigger, session }) {
// Initial sign in
if (user) {
token.id = user.id as string
@@ -230,16 +230,66 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.mustSetPassword = user.mustSetPassword
}
// On session update, refresh from database
if (trigger === 'update') {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { role: true, roles: true, mustSetPassword: true },
})
if (dbUser) {
token.role = dbUser.role
token.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
token.mustSetPassword = dbUser.mustSetPassword
// On session update, handle impersonation or normal refresh
if (trigger === 'update' && session) {
// Start impersonation
if (session.impersonate && typeof session.impersonate === 'string') {
// Only SUPER_ADMIN can impersonate (defense-in-depth)
if (token.role === 'SUPER_ADMIN' && !token.impersonating) {
const targetUser = await prisma.user.findUnique({
where: { id: session.impersonate },
select: { id: true, email: true, name: true, role: true, roles: true, status: true },
})
if (targetUser && targetUser.status !== 'SUSPENDED' && targetUser.role !== 'SUPER_ADMIN') {
// Save original admin identity
token.impersonating = {
originalId: token.id as string,
originalRole: token.role as UserRole,
originalRoles: (token.roles as UserRole[]) ?? [token.role as UserRole],
originalEmail: token.email as string,
}
// Swap to target user
token.id = targetUser.id
token.email = targetUser.email
token.name = targetUser.name
token.role = targetUser.role
token.roles = targetUser.roles.length ? targetUser.roles : [targetUser.role]
token.mustSetPassword = false
}
}
}
// End impersonation
else if (session.endImpersonation && token.impersonating) {
const original = token.impersonating as { originalId: string; originalRole: UserRole; originalRoles: UserRole[]; originalEmail: string }
token.id = original.originalId
token.role = original.originalRole
token.roles = original.originalRoles
token.email = original.originalEmail
token.impersonating = undefined
token.mustSetPassword = false
// Refresh original admin's name
const adminUser = await prisma.user.findUnique({
where: { id: original.originalId },
select: { name: true },
})
if (adminUser) {
token.name = adminUser.name
}
}
// Normal session refresh
else {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { role: true, roles: true, mustSetPassword: true },
})
if (dbUser) {
token.role = dbUser.role
token.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
// Don't override mustSetPassword=false during impersonation
if (!token.impersonating) {
token.mustSetPassword = dbUser.mustSetPassword
}
}
}
}
@@ -251,6 +301,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
session.user.role = token.role as UserRole
session.user.roles = (token.roles as UserRole[]) ?? [token.role as UserRole]
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
session.user.impersonating = token.impersonating as typeof session.user.impersonating
}
return session
},