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

@@ -0,0 +1,51 @@
'use client'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export function ImpersonationBanner() {
const { data: session, update } = useSession()
const router = useRouter()
const endImpersonation = trpc.user.endImpersonation.useMutation()
if (!session?.user?.impersonating) return null
const handleReturn = async () => {
try {
await endImpersonation.mutateAsync()
await update({ endImpersonation: true })
toast.success('Returned to admin account')
router.push('/admin/members')
router.refresh()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to end impersonation')
}
}
return (
<div className="fixed top-0 left-0 right-0 z-50 flex items-center justify-center gap-3 bg-red-600 px-4 py-1.5 text-sm text-white shadow-md">
<span>
Impersonating <strong>{session.user.name || session.user.email}</strong>{' '}
({session.user.role.replace('_', ' ')})
</span>
<Button
size="sm"
variant="secondary"
className="h-6 px-3 text-xs"
onClick={handleReturn}
disabled={endImpersonation.isPending}
>
{endImpersonation.isPending ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<ArrowLeft className="mr-1 h-3 w-3" />
)}
Return to Admin
</Button>
</div>
)
}