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:
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user