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:
@@ -1,6 +1,13 @@
|
||||
import type { NextAuthConfig } from 'next-auth'
|
||||
import type { UserRole } from '@prisma/client'
|
||||
|
||||
type ImpersonationInfo = {
|
||||
originalId: string
|
||||
originalRole: UserRole
|
||||
originalRoles: UserRole[]
|
||||
originalEmail: string
|
||||
}
|
||||
|
||||
// Extend the built-in session types
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
@@ -11,6 +18,7 @@ declare module 'next-auth' {
|
||||
role: UserRole
|
||||
roles: UserRole[]
|
||||
mustSetPassword?: boolean
|
||||
impersonating?: ImpersonationInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +35,7 @@ declare module '@auth/core/jwt' {
|
||||
role: UserRole
|
||||
roles?: UserRole[]
|
||||
mustSetPassword?: boolean
|
||||
impersonating?: ImpersonationInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,15 +70,16 @@ export const authConfig: NextAuthConfig = {
|
||||
return false // Will redirect to signIn page
|
||||
}
|
||||
|
||||
// Check if user needs to set password
|
||||
// Check if user needs to set password (skip during impersonation)
|
||||
const mustSetPassword = auth?.user?.mustSetPassword
|
||||
const isImpersonating = !!(auth?.user as Record<string, unknown>)?.impersonating
|
||||
const passwordSetupAllowedPaths = [
|
||||
'/set-password',
|
||||
'/api/auth',
|
||||
'/api/trpc',
|
||||
]
|
||||
|
||||
if (mustSetPassword) {
|
||||
if (mustSetPassword && !isImpersonating) {
|
||||
// Allow access to password setup related paths
|
||||
if (passwordSetupAllowedPaths.some((path) => pathname.startsWith(path))) {
|
||||
return true
|
||||
|
||||
@@ -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