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

@@ -116,20 +116,21 @@ function getContextualActions(
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) { export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery( const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
{ editionId }, { editionId },
{ enabled: !!editionId, retry: 1, refetchInterval: 30_000 } { enabled: !!editionId, refetchInterval: 60_000 }
) )
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery( const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
{ editionId, limit: 8 }, { editionId, limit: 8 },
{ enabled: !!editionId, refetchInterval: 30_000 } { enabled: !!editionId, refetchInterval: 60_000 }
) )
const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery( const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery(
{ limit: 8 }, { limit: 8 },
{ enabled: !!editionId, refetchInterval: 5_000 } { enabled: !!editionId, refetchInterval: 30_000 }
) )
const { data: semiFinalistStats } = trpc.dashboard.getSemiFinalistStats.useQuery( const { data: semiFinalistStats } = trpc.dashboard.getSemiFinalistStats.useQuery(
{ editionId }, { editionId },
{ enabled: !!editionId, refetchInterval: 60_000 } { enabled: !!editionId, refetchInterval: 120_000 }
) )
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery()
if (isLoading) { if (isLoading) {
return <DashboardSkeleton /> return <DashboardSkeleton />
@@ -283,6 +284,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
byAward={semiFinalistStats.byAward} byAward={semiFinalistStats.byAward}
unactivatedProjects={semiFinalistStats.unactivatedProjects} unactivatedProjects={semiFinalistStats.unactivatedProjects}
editionId={editionId} editionId={editionId}
reminderThresholdDays={featureFlags?.accountReminderDays}
/> />
</AnimatedCard> </AnimatedCard>
)} )}

View 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} />
}

View File

@@ -11,8 +11,10 @@ export default async function ApplicantLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
const session = await requireRole('APPLICANT') const session = await requireRole('APPLICANT')
const isImpersonating = !!session.user.impersonating
// Check if user has completed onboarding // Check if user has completed onboarding (skip during impersonation)
if (!isImpersonating) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },
select: { onboardingCompletedAt: true }, select: { onboardingCompletedAt: true },
@@ -25,6 +27,7 @@ export default async function ApplicantLayout({
if (!user.onboardingCompletedAt) { if (!user.onboardingCompletedAt) {
redirect('/onboarding') redirect('/onboarding')
} }
}
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">

View File

@@ -11,21 +11,23 @@ export default async function JuryLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
const session = await requireRole('JURY_MEMBER') const session = await requireRole('JURY_MEMBER')
const isImpersonating = !!session.user.impersonating
// Check if user has completed onboarding // Check if user has completed onboarding (skip during impersonation)
if (!isImpersonating) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },
select: { onboardingCompletedAt: true }, select: { onboardingCompletedAt: true },
}) })
if (!user) { if (!user) {
// User was deleted — session is stale, send to login
redirect('/login') redirect('/login')
} }
if (!user.onboardingCompletedAt) { if (!user.onboardingCompletedAt) {
redirect('/onboarding') redirect('/onboarding')
} }
}
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">

View File

@@ -12,16 +12,16 @@ export default async function MentorLayout({
}) { }) {
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN') 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] 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({ const user = await prisma.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },
select: { onboardingCompletedAt: true }, select: { onboardingCompletedAt: true },
}) })
if (!user) { if (!user) {
// User was deleted — session is stale, send to login
redirect('/login') redirect('/login')
} }

View File

@@ -12,8 +12,10 @@ export default async function ObserverLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
const session = await requireRole('OBSERVER') const session = await requireRole('OBSERVER')
const isImpersonating = !!session.user.impersonating
// Check if user has completed onboarding // Check if user has completed onboarding (skip during impersonation)
if (!isImpersonating) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },
select: { onboardingCompletedAt: true }, select: { onboardingCompletedAt: true },
@@ -26,6 +28,7 @@ export default async function ObserverLayout({
if (!user.onboardingCompletedAt) { if (!user.onboardingCompletedAt) {
redirect('/onboarding') redirect('/onboarding')
} }
}
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">

View File

@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import './globals.css' import './globals.css'
import { Providers } from './providers' import { Providers } from './providers'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -22,7 +23,10 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers> <Providers>
<ImpersonationBanner />
{children}
</Providers>
<Toaster <Toaster
position="top-right" position="top-right"
toastOptions={{ toastOptions={{

View File

@@ -14,6 +14,16 @@ function makeQueryClient() {
queries: { queries: {
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false, 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({ httpBatchLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
transformer: superjson, 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
},
}), }),
], ],
}) })

View File

@@ -0,0 +1,265 @@
'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Users,
Search,
CheckCircle2,
AlertCircle,
Clock,
ArrowLeft,
Loader2,
} from 'lucide-react'
const categoryLabels: Record<string, string> = {
STARTUP: 'Startup',
BUSINESS_CONCEPT: 'Business Concept',
}
const statusConfig = {
active: { label: 'Active', color: 'bg-emerald-500', icon: CheckCircle2 },
invited: { label: 'Invited', color: 'bg-amber-500', icon: Clock },
none: { label: 'No Account', color: 'bg-red-500', icon: AlertCircle },
} as const
type SemiFinalistsContentProps = {
editionId: string
}
export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) {
const { data, isLoading } = trpc.dashboard.getSemiFinalistDetail.useQuery(
{ editionId },
{ enabled: !!editionId }
)
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all')
const filtered = useMemo(() => {
if (!data) return []
let items = data
if (categoryFilter !== 'all') {
items = items.filter(p => p.category === categoryFilter)
}
if (statusFilter === 'activated') {
items = items.filter(p => p.allActivated)
} else if (statusFilter === 'pending') {
items = items.filter(p => !p.allActivated)
}
if (search.trim()) {
const q = search.toLowerCase()
items = items.filter(p =>
p.title.toLowerCase().includes(q) ||
p.teamName?.toLowerCase().includes(q) ||
p.country?.toLowerCase().includes(q) ||
p.teamMembers.some(tm =>
tm.name?.toLowerCase().includes(q) ||
tm.email.toLowerCase().includes(q)
)
)
}
return items
}, [data, search, categoryFilter, statusFilter])
const stats = useMemo(() => {
if (!data) return { total: 0, activated: 0, pending: 0 }
return {
total: data.length,
activated: data.filter(p => p.allActivated).length,
pending: data.filter(p => !p.allActivated).length,
}
}, [data])
if (isLoading) {
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href={'/admin' as Route}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold tracking-tight md:text-2xl">
Semi-Finalists
</h1>
<p className="text-sm text-muted-foreground">
{stats.total} projects &middot; {stats.activated} fully activated &middot; {stats.pending} pending
</p>
</div>
</div>
</div>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-3 sm:flex-row">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by project, team, member name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Account Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="activated">Fully Activated</SelectItem>
<SelectItem value="pending">Pending Setup</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Table */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4 text-brand-blue" />
{filtered.length} project{filtered.length !== 1 ? 's' : ''}
</CardTitle>
</CardHeader>
<CardContent>
{filtered.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No semi-finalist projects match your filters.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Country</TableHead>
<TableHead>Current Round</TableHead>
<TableHead>Team Members</TableHead>
<TableHead className="text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((project) => (
<TableRow key={project.projectId}>
<TableCell>
<Link
href={`/admin/projects/${project.projectId}` as Route}
className="font-medium text-brand-blue hover:underline"
>
{project.title}
</Link>
{project.teamName && (
<p className="text-xs text-muted-foreground">{project.teamName}</p>
)}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{categoryLabels[project.category ?? ''] ?? project.category}
</Badge>
</TableCell>
<TableCell className="text-sm">{project.country || '—'}</TableCell>
<TableCell className="text-sm">{project.currentRound}</TableCell>
<TableCell>
<TooltipProvider>
<div className="space-y-1">
{project.teamMembers.map((tm, idx) => {
const cfg = statusConfig[tm.accountStatus]
const Icon = cfg.icon
return (
<Tooltip key={idx}>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${cfg.color}`} />
<span className="max-w-[180px] truncate">
{tm.name || tm.email}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{tm.email}</p>
<p className="text-xs text-muted-foreground">
{cfg.label}
{tm.lastLogin && ` · Last login: ${new Date(tm.lastLogin).toLocaleDateString()}`}
</p>
</TooltipContent>
</Tooltip>
)
})}
</div>
</TooltipProvider>
</TableCell>
<TableCell className="text-center">
{project.allActivated ? (
<CheckCircle2 className="mx-auto h-4 w-4 text-emerald-500" />
) : (
<AlertCircle className="mx-auto h-4 w-4 text-amber-500" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -2,6 +2,9 @@
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link' 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 { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -33,6 +36,7 @@ import {
Trash2, Trash2,
Loader2, Loader2,
Shield, Shield,
LogIn,
} from 'lucide-react' } from 'lucide-react'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
@@ -54,9 +58,21 @@ interface UserActionsProps {
currentUserRole?: Role 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) { export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const { data: session, update } = useSession()
const router = useRouter()
const utils = trpc.useUtils() const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation() const sendInvitation = trpc.user.sendInvitation.useMutation()
@@ -65,6 +81,7 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
utils.user.list.invalidate() utils.user.list.invalidate()
}, },
}) })
const startImpersonation = trpc.user.startImpersonation.useMutation()
const updateRoles = trpc.user.updateRoles.useMutation({ const updateRoles = trpc.user.updateRoles.useMutation({
onSuccess: () => { onSuccess: () => {
utils.user.list.invalidate() utils.user.list.invalidate()
@@ -105,6 +122,18 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
updateRoles.mutate({ userId, roles: newRoles }) 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 () => { const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') { if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation') toast.error('User has already accepted their invitation')
@@ -154,6 +183,19 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
Edit Edit
</Link> </Link>
</DropdownMenuItem> </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 && ( {canChangeRole && (
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger disabled={updateRoles.isPending}> <DropdownMenuSubTrigger disabled={updateRoles.isPending}>
@@ -237,8 +279,11 @@ export function UserMobileActions({
currentUserRole, currentUserRole,
}: UserMobileActionsProps) { }: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const { data: session, update } = useSession()
const router = useRouter()
const utils = trpc.useUtils() const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation() const sendInvitation = trpc.user.sendInvitation.useMutation()
const startImpersonation = trpc.user.startImpersonation.useMutation()
const updateRoles = trpc.user.updateRoles.useMutation({ const updateRoles = trpc.user.updateRoles.useMutation({
onSuccess: () => { onSuccess: () => {
utils.user.list.invalidate() utils.user.list.invalidate()
@@ -253,6 +298,18 @@ export function UserMobileActions({
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole)) const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const currentRoles: Role[] = userRoles?.length ? userRoles : [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 () => { const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') { if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation') toast.error('User has already accepted their invitation')
@@ -280,6 +337,22 @@ export function UserMobileActions({
Edit Edit
</Link> </Link>
</Button> </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 <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@@ -5,6 +5,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import Link from 'next/link'
import type { Route } from 'next'
import { import {
Users, Users,
Send, Send,
@@ -12,6 +14,7 @@ import {
AlertCircle, AlertCircle,
Trophy, Trophy,
Loader2, Loader2,
ExternalLink,
} from 'lucide-react' } from 'lucide-react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -44,6 +47,7 @@ type SemiFinalistTrackerProps = {
byAward: AwardStat[] byAward: AwardStat[]
unactivatedProjects: UnactivatedProject[] unactivatedProjects: UnactivatedProject[]
editionId: string editionId: string
reminderThresholdDays?: number
} }
const categoryLabels: Record<string, string> = { const categoryLabels: Record<string, string> = {
@@ -57,6 +61,7 @@ export function SemiFinalistTracker({
byAward, byAward,
unactivatedProjects, unactivatedProjects,
editionId, editionId,
reminderThresholdDays = 3,
}: SemiFinalistTrackerProps) { }: SemiFinalistTrackerProps) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const sendReminders = trpc.dashboard.sendAccountReminders.useMutation({ const sendReminders = trpc.dashboard.sendAccountReminders.useMutation({
@@ -97,9 +102,16 @@ export function SemiFinalistTracker({
<Users className="h-4 w-4 text-brand-blue" /> <Users className="h-4 w-4 text-brand-blue" />
Semi-Finalist Tracker Semi-Finalist Tracker
</CardTitle> </CardTitle>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{totalActivated}/{totalProjects} activated {totalActivated}/{totalProjects} activated
</Badge> </Badge>
<Link href={`/admin/semi-finalists?editionId=${editionId}` as Route}>
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs">
See All <ExternalLink className="ml-1 h-3 w-3" />
</Button>
</Link>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">

View File

@@ -383,7 +383,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</TabsContent> </TabsContent>
)} )}
<TabsContent value="notifications"> <TabsContent value="notifications" className="space-y-6">
<AnimatedCard> <AnimatedCard>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -397,6 +397,25 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Account Reminders</CardTitle>
<CardDescription>
Configure when account setup reminders become appropriate
</CardDescription>
</CardHeader>
<CardContent>
<SettingInput
label="Days before account reminder"
description="Number of days after advancement before showing a warning icon and enabling reminder emails for unactivated accounts"
settingKey="account_reminder_days"
value={initialSettings.account_reminder_days || '3'}
type="number"
/>
</CardContent>
</Card>
</AnimatedCard>
</TabsContent> </TabsContent>
{isSuperAdmin && ( {isSuperAdmin && (

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>
)
}

View File

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

View File

@@ -221,7 +221,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
], ],
callbacks: { callbacks: {
...authConfig.callbacks, ...authConfig.callbacks,
async jwt({ token, user, trigger }) { async jwt({ token, user, trigger, session }) {
// Initial sign in // Initial sign in
if (user) { if (user) {
token.id = user.id as string token.id = user.id as string
@@ -230,8 +230,54 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.mustSetPassword = user.mustSetPassword token.mustSetPassword = user.mustSetPassword
} }
// On session update, refresh from database // On session update, handle impersonation or normal refresh
if (trigger === 'update') { 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({ const dbUser = await prisma.user.findUnique({
where: { id: token.id as string }, where: { id: token.id as string },
select: { role: true, roles: true, mustSetPassword: true }, select: { role: true, roles: true, mustSetPassword: true },
@@ -239,9 +285,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (dbUser) { if (dbUser) {
token.role = dbUser.role token.role = dbUser.role
token.roles = dbUser.roles.length ? dbUser.roles : [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 token.mustSetPassword = dbUser.mustSetPassword
} }
} }
}
}
return token return token
}, },
@@ -251,6 +301,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
session.user.role = token.role as UserRole session.user.role = token.role as UserRole
session.user.roles = (token.roles as UserRole[]) ?? [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.mustSetPassword = token.mustSetPassword as boolean | undefined
session.user.impersonating = token.impersonating as typeof session.user.impersonating
} }
return session return session
}, },

View File

@@ -586,15 +586,16 @@ export const dashboardRouter = router({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { editionId } = input const { editionId } = input
// Find all projects with at least one PASSED state in this edition's rounds. // Find projects whose LATEST terminal state (PASSED/REJECTED/WITHDRAWN) is PASSED.
// Use the highest sortOrder PASSED round per project to avoid double-counting. // A project that passed round 1 but was rejected in round 2 is NOT a semi-finalist.
const passedStates = await ctx.prisma.projectRoundState.findMany({ const terminalStates = await ctx.prisma.projectRoundState.findMany({
where: { where: {
state: 'PASSED', state: { in: ['PASSED', 'REJECTED', 'WITHDRAWN'] },
round: { competition: { programId: editionId } }, round: { competition: { programId: editionId } },
}, },
select: { select: {
projectId: true, projectId: true,
state: true,
round: { select: { id: true, name: true, sortOrder: true } }, round: { select: { id: true, name: true, sortOrder: true } },
project: { project: {
select: { select: {
@@ -608,6 +609,7 @@ export const dashboardRouter = router({
id: true, id: true,
email: true, email: true,
name: true, name: true,
status: true,
passwordHash: true, passwordHash: true,
}, },
}, },
@@ -618,16 +620,17 @@ export const dashboardRouter = router({
}, },
}) })
// Deduplicate: keep only the highest-sortOrder PASSED round per project // For each project, keep only the terminal state from the highest-sortOrder round
const projectMap = new Map<string, (typeof passedStates)[0]>() const projectMap = new Map<string, (typeof terminalStates)[0]>()
for (const ps of passedStates) { for (const ts of terminalStates) {
const existing = projectMap.get(ps.projectId) const existing = projectMap.get(ts.projectId)
if (!existing || ps.round.sortOrder > existing.round.sortOrder) { if (!existing || ts.round.sortOrder > existing.round.sortOrder) {
projectMap.set(ps.projectId, ps) projectMap.set(ts.projectId, ts)
} }
} }
const uniqueProjects = Array.from(projectMap.values()) // Only include projects whose latest terminal state is PASSED
const uniqueProjects = Array.from(projectMap.values()).filter(ps => ps.state === 'PASSED')
// Group by category // Group by category
const catMap = new Map<string, { total: number; accountsSet: number; accountsNotSet: number }>() const catMap = new Map<string, { total: number; accountsSet: number; accountsNotSet: number }>()
@@ -636,7 +639,7 @@ export const dashboardRouter = router({
if (!catMap.has(cat)) catMap.set(cat, { total: 0, accountsSet: 0, accountsNotSet: 0 }) if (!catMap.has(cat)) catMap.set(cat, { total: 0, accountsSet: 0, accountsNotSet: 0 })
const entry = catMap.get(cat)! const entry = catMap.get(cat)!
entry.total++ entry.total++
const hasActivated = ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null) const hasActivated = ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE')
if (hasActivated) entry.accountsSet++ if (hasActivated) entry.accountsSet++
else entry.accountsNotSet++ else entry.accountsNotSet++
} }
@@ -676,7 +679,7 @@ export const dashboardRouter = router({
for (const pid of projectIds) { for (const pid of projectIds) {
const ps = projectMap.get(pid) const ps = projectMap.get(pid)
if (ps) { if (ps) {
const hasActivated = ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null) const hasActivated = ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE')
if (hasActivated) accountsSet++ if (hasActivated) accountsSet++
else accountsNotSet++ else accountsNotSet++
} }
@@ -684,15 +687,15 @@ export const dashboardRouter = router({
return { awardId, awardName, total: projectIds.size, accountsSet, accountsNotSet } return { awardId, awardName, total: projectIds.size, accountsSet, accountsNotSet }
}) })
// Unactivated projects: no team member has passwordHash // Unactivated projects: no team member has set up their account
const unactivatedProjects = uniqueProjects const unactivatedProjects = uniqueProjects
.filter((ps) => !ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null)) .filter((ps) => !ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE'))
.map((ps) => ({ .map((ps) => ({
projectId: ps.projectId, projectId: ps.projectId,
projectTitle: ps.project.title, projectTitle: ps.project.title,
category: ps.project.competitionCategory, category: ps.project.competitionCategory,
teamEmails: ps.project.teamMembers teamEmails: ps.project.teamMembers
.filter((tm) => tm.user.passwordHash === null) .filter((tm) => tm.user.passwordHash === null && tm.user.status !== 'ACTIVE')
.map((tm) => tm.user.email), .map((tm) => tm.user.email),
roundName: ps.round.name, roundName: ps.round.name,
})) }))
@@ -700,6 +703,94 @@ export const dashboardRouter = router({
return { byCategory, byAward, unactivatedProjects } return { byCategory, byAward, unactivatedProjects }
}), }),
/**
* Get detailed semi-finalist list for the "See All" page.
* Returns every project whose latest terminal state is PASSED, with team and round info.
*/
getSemiFinalistDetail: adminProcedure
.input(z.object({ editionId: z.string() }))
.query(async ({ ctx, input }) => {
const { editionId } = input
// Fetch all terminal states for projects in this edition
const terminalStates = await ctx.prisma.projectRoundState.findMany({
where: {
state: { in: ['PASSED', 'REJECTED', 'WITHDRAWN'] },
round: { competition: { programId: editionId } },
},
select: {
projectId: true,
state: true,
round: { select: { id: true, name: true, sortOrder: true, roundType: true } },
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
country: true,
teamMembers: {
select: {
role: true,
user: {
select: {
id: true,
email: true,
name: true,
status: true,
passwordHash: true,
lastLoginAt: true,
},
},
},
},
},
},
},
})
// Keep the latest terminal state per project
const projectMap = new Map<string, (typeof terminalStates)[0]>()
for (const ts of terminalStates) {
const existing = projectMap.get(ts.projectId)
if (!existing || ts.round.sortOrder > existing.round.sortOrder) {
projectMap.set(ts.projectId, ts)
}
}
// Only include PASSED projects
const semiFinalists = Array.from(projectMap.values())
.filter(ps => ps.state === 'PASSED')
.map(ps => ({
projectId: ps.projectId,
title: ps.project.title,
teamName: ps.project.teamName,
category: ps.project.competitionCategory,
country: ps.project.country,
currentRound: ps.round.name,
currentRoundType: ps.round.roundType,
teamMembers: ps.project.teamMembers.map(tm => ({
name: tm.user.name,
email: tm.user.email,
role: tm.role,
accountStatus: tm.user.passwordHash !== null
? 'active' as const
: tm.user.status === 'ACTIVE'
? 'active' as const
: tm.user.status === 'INVITED'
? 'invited' as const
: 'none' as const,
lastLogin: tm.user.lastLoginAt,
})),
allActivated: ps.project.teamMembers.every(
tm => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE'
),
}))
.sort((a, b) => a.title.localeCompare(b.title))
return semiFinalists
}),
/** /**
* Send account setup reminder emails to semi-finalist team members * Send account setup reminder emails to semi-finalist team members
* who haven't set their password yet. * who haven't set their password yet.
@@ -768,6 +859,7 @@ export const dashboardRouter = router({
id: true, id: true,
email: true, email: true,
name: true, name: true,
status: true,
passwordHash: true, passwordHash: true,
}, },
}, },
@@ -794,7 +886,7 @@ export const dashboardRouter = router({
for (const project of projects) { for (const project of projects) {
const unactivated = project.teamMembers.filter( const unactivated = project.teamMembers.filter(
(tm) => tm.user.passwordHash === null && !recentReminderEmails.has(tm.user.email) (tm) => tm.user.passwordHash === null && tm.user.status !== 'ACTIVE' && !recentReminderEmails.has(tm.user.email)
) )
for (const tm of unactivated) { for (const tm of unactivated) {

View File

@@ -42,7 +42,7 @@ export const settingsRouter = router({
* These are non-sensitive settings that can be exposed to any user * These are non-sensitive settings that can be exposed to any user
*/ */
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => { getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
const [whatsappEnabled, juryCompareEnabled, learningHubExternal, learningHubExternalUrl, supportEmail] = await Promise.all([ const [whatsappEnabled, juryCompareEnabled, learningHubExternal, learningHubExternalUrl, supportEmail, accountReminderDays] = await Promise.all([
ctx.prisma.systemSettings.findUnique({ ctx.prisma.systemSettings.findUnique({
where: { key: 'whatsapp_enabled' }, where: { key: 'whatsapp_enabled' },
}), }),
@@ -58,6 +58,9 @@ export const settingsRouter = router({
ctx.prisma.systemSettings.findUnique({ ctx.prisma.systemSettings.findUnique({
where: { key: 'support_email' }, where: { key: 'support_email' },
}), }),
ctx.prisma.systemSettings.findUnique({
where: { key: 'account_reminder_days' },
}),
]) ])
return { return {
@@ -66,6 +69,7 @@ export const settingsRouter = router({
learningHubExternal: learningHubExternal?.value === 'true', learningHubExternal: learningHubExternal?.value === 'true',
learningHubExternalUrl: learningHubExternalUrl?.value || '', learningHubExternalUrl: learningHubExternalUrl?.value || '',
supportEmail: supportEmail?.value || '', supportEmail: supportEmail?.value || '',
accountReminderDays: parseInt(accountReminderDays?.value || '3', 10),
} }
}), }),

View File

@@ -1689,4 +1689,86 @@ export const userRouter = router({
return { sent, skipped, failed } return { sent, skipped, failed }
}), }),
/**
* Start impersonating a user (super admin only)
*/
startImpersonation: superAdminProcedure
.input(z.object({ targetUserId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Block nested impersonation
if ((ctx.session as unknown as { user?: { impersonating?: unknown } })?.user?.impersonating) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot start nested impersonation. End current impersonation first.',
})
}
const target = await ctx.prisma.user.findUnique({
where: { id: input.targetUserId },
select: { id: true, email: true, name: true, role: true, roles: true, status: true },
})
if (!target) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' })
}
if (target.status === 'SUSPENDED') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot impersonate a suspended user' })
}
if (target.role === 'SUPER_ADMIN') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot impersonate another super admin' })
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'IMPERSONATION_START',
entityType: 'User',
entityId: target.id,
detailsJson: {
adminId: ctx.user.id,
adminEmail: ctx.user.email,
targetId: target.id,
targetEmail: target.email,
targetRole: target.role,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { targetUserId: target.id, targetRole: target.role }
}),
/**
* End impersonation and return to admin
*/
endImpersonation: protectedProcedure
.mutation(async ({ ctx }) => {
const session = ctx.session as unknown as { user?: { impersonating?: { originalId: string; originalEmail: string } } }
const impersonating = session?.user?.impersonating
if (!impersonating) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not currently impersonating' })
}
await logAudit({
prisma: ctx.prisma,
userId: impersonating.originalId,
action: 'IMPERSONATION_END',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: {
adminId: impersonating.originalId,
adminEmail: impersonating.originalEmail,
targetId: ctx.user.id,
targetEmail: ctx.user.email,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { ended: true }
}),
}) })