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:
@@ -116,20 +116,21 @@ function getContextualActions(
|
||||
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
|
||||
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
|
||||
{ editionId },
|
||||
{ enabled: !!editionId, retry: 1, refetchInterval: 30_000 }
|
||||
{ enabled: !!editionId, refetchInterval: 60_000 }
|
||||
)
|
||||
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
|
||||
{ editionId, limit: 8 },
|
||||
{ enabled: !!editionId, refetchInterval: 30_000 }
|
||||
{ enabled: !!editionId, refetchInterval: 60_000 }
|
||||
)
|
||||
const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery(
|
||||
{ limit: 8 },
|
||||
{ enabled: !!editionId, refetchInterval: 5_000 }
|
||||
{ enabled: !!editionId, refetchInterval: 30_000 }
|
||||
)
|
||||
const { data: semiFinalistStats } = trpc.dashboard.getSemiFinalistStats.useQuery(
|
||||
{ editionId },
|
||||
{ enabled: !!editionId, refetchInterval: 60_000 }
|
||||
{ enabled: !!editionId, refetchInterval: 120_000 }
|
||||
)
|
||||
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery()
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />
|
||||
@@ -283,6 +284,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
byAward={semiFinalistStats.byAward}
|
||||
unactivatedProjects={semiFinalistStats.unactivatedProjects}
|
||||
editionId={editionId}
|
||||
reminderThresholdDays={featureFlags?.accountReminderDays}
|
||||
/>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
42
src/app/(admin)/admin/semi-finalists/page.tsx
Normal file
42
src/app/(admin)/admin/semi-finalists/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SemiFinalistsContent } from '@/components/admin/semi-finalists-content'
|
||||
|
||||
export const metadata: Metadata = { title: 'Semi-Finalists' }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ editionId?: string }>
|
||||
}
|
||||
|
||||
export default async function SemiFinalistsPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams
|
||||
let editionId = params.editionId || null
|
||||
|
||||
if (!editionId) {
|
||||
const defaultEdition = await prisma.program.findFirst({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
editionId = defaultEdition?.id || null
|
||||
|
||||
if (!editionId) {
|
||||
const anyEdition = await prisma.program.findFirst({
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
editionId = anyEdition?.id || null
|
||||
}
|
||||
}
|
||||
|
||||
if (!editionId) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
No edition found.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <SemiFinalistsContent editionId={editionId} />
|
||||
}
|
||||
@@ -11,19 +11,22 @@ export default async function ApplicantLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await requireRole('APPLICANT')
|
||||
const isImpersonating = !!session.user.impersonating
|
||||
|
||||
// Check if user has completed onboarding
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { onboardingCompletedAt: true },
|
||||
})
|
||||
// Check if user has completed onboarding (skip during impersonation)
|
||||
if (!isImpersonating) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { onboardingCompletedAt: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
if (!user.onboardingCompletedAt) {
|
||||
redirect('/onboarding')
|
||||
if (!user.onboardingCompletedAt) {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,20 +11,22 @@ export default async function JuryLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await requireRole('JURY_MEMBER')
|
||||
const isImpersonating = !!session.user.impersonating
|
||||
|
||||
// Check if user has completed onboarding
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { onboardingCompletedAt: true },
|
||||
})
|
||||
// Check if user has completed onboarding (skip during impersonation)
|
||||
if (!isImpersonating) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { onboardingCompletedAt: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// User was deleted — session is stale, send to login
|
||||
redirect('/login')
|
||||
}
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
if (!user.onboardingCompletedAt) {
|
||||
redirect('/onboarding')
|
||||
if (!user.onboardingCompletedAt) {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,16 +12,16 @@ export default async function MentorLayout({
|
||||
}) {
|
||||
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
|
||||
|
||||
// Check if user has completed onboarding (for mentors)
|
||||
// Check if user has completed onboarding (for mentors, skip during impersonation)
|
||||
const isImpersonating = !!session.user.impersonating
|
||||
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
|
||||
if (userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) {
|
||||
if (!isImpersonating && userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { onboardingCompletedAt: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// User was deleted — session is stale, send to login
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
|
||||
@@ -12,19 +12,22 @@ export default async function ObserverLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await requireRole('OBSERVER')
|
||||
const isImpersonating = !!session.user.impersonating
|
||||
|
||||
// Check if user has completed onboarding
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { onboardingCompletedAt: true },
|
||||
})
|
||||
// Check if user has completed onboarding (skip during impersonation)
|
||||
if (!isImpersonating) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { onboardingCompletedAt: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
if (!user.onboardingCompletedAt) {
|
||||
redirect('/onboarding')
|
||||
if (!user.onboardingCompletedAt) {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
import { Toaster } from 'sonner'
|
||||
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -22,7 +23,10 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
<Providers>
|
||||
<ImpersonationBanner />
|
||||
{children}
|
||||
</Providers>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
|
||||
@@ -14,6 +14,16 @@ function makeQueryClient() {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (failureCount, error) => {
|
||||
// Retry up to 3 times on server errors (503 cold-start, etc.)
|
||||
if (failureCount >= 3) return false
|
||||
const msg = (error as Error)?.message ?? ''
|
||||
// Retry on JSON parse errors (HTML 503 from nginx) and server errors
|
||||
if (msg.includes('is not valid JSON') || msg.includes('Unexpected token')) return true
|
||||
if (msg.includes('500') || msg.includes('502') || msg.includes('503')) return true
|
||||
return failureCount < 2
|
||||
},
|
||||
retryDelay: (attemptIndex) => Math.min(2000 * (attemptIndex + 1), 8000),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -47,6 +57,21 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
transformer: superjson,
|
||||
async fetch(url, options) {
|
||||
const res = await globalThis.fetch(url, options)
|
||||
// Detect nginx 503 / HTML error pages before tRPC tries to JSON.parse
|
||||
if (!res.ok) {
|
||||
const ct = res.headers.get('content-type') ?? ''
|
||||
if (ct.includes('text/html') || !ct.includes('json')) {
|
||||
throw new Error(
|
||||
res.status >= 500
|
||||
? 'Server is starting up — please wait a moment and try again.'
|
||||
: `Server error (${res.status})`
|
||||
)
|
||||
}
|
||||
}
|
||||
return res
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user