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

@@ -2,6 +2,9 @@
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
@@ -33,6 +36,7 @@ import {
Trash2,
Loader2,
Shield,
LogIn,
} from 'lucide-react'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
@@ -54,9 +58,21 @@ interface UserActionsProps {
currentUserRole?: Role
}
function getRoleHomePath(role: string): string {
switch (role) {
case 'JURY_MEMBER': return '/jury'
case 'APPLICANT': return '/applicant'
case 'MENTOR': return '/mentor'
case 'OBSERVER': return '/observer'
default: return '/admin'
}
}
export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false)
const { data: session, update } = useSession()
const router = useRouter()
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
@@ -65,6 +81,7 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
utils.user.list.invalidate()
},
})
const startImpersonation = trpc.user.startImpersonation.useMutation()
const updateRoles = trpc.user.updateRoles.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
@@ -105,6 +122,18 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
updateRoles.mutate({ userId, roles: newRoles })
}
const handleImpersonate = async () => {
try {
const result = await startImpersonation.mutateAsync({ targetUserId: userId })
await update({ impersonate: userId })
toast.success(`Now impersonating ${userEmail}`)
router.push(getRoleHomePath(result.targetRole) as Route)
router.refresh()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')
}
}
const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
@@ -154,6 +183,19 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
Edit
</Link>
</DropdownMenuItem>
{isSuperAdmin && session?.user?.id !== userId && (
<DropdownMenuItem
onClick={handleImpersonate}
disabled={startImpersonation.isPending}
>
{startImpersonation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogIn className="mr-2 h-4 w-4" />
)}
Login As
</DropdownMenuItem>
)}
{canChangeRole && (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={updateRoles.isPending}>
@@ -237,8 +279,11 @@ export function UserMobileActions({
currentUserRole,
}: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false)
const { data: session, update } = useSession()
const router = useRouter()
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const startImpersonation = trpc.user.startImpersonation.useMutation()
const updateRoles = trpc.user.updateRoles.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
@@ -253,6 +298,18 @@ export function UserMobileActions({
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole]
const handleImpersonateMobile = async () => {
try {
const result = await startImpersonation.mutateAsync({ targetUserId: userId })
await update({ impersonate: userId })
toast.success(`Now impersonating ${userEmail}`)
router.push(getRoleHomePath(result.targetRole) as Route)
router.refresh()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')
}
}
const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
@@ -280,6 +337,22 @@ export function UserMobileActions({
Edit
</Link>
</Button>
{isSuperAdmin && session?.user?.id !== userId && (
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={handleImpersonateMobile}
disabled={startImpersonation.isPending}
>
{startImpersonation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogIn className="mr-2 h-4 w-4" />
)}
Login As
</Button>
)}
<Button
variant="outline"
size="sm"