From 6c52e519e574afaa8b3af96e78df9bf4d5000c5c Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Mar 2026 17:55:44 +0100 Subject: [PATCH] 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 --- src/app/(admin)/admin/dashboard-content.tsx | 10 +- src/app/(admin)/admin/semi-finalists/page.tsx | 42 +++ src/app/(applicant)/layout.tsx | 23 +- src/app/(jury)/layout.tsx | 24 +- src/app/(mentor)/layout.tsx | 6 +- src/app/(observer)/layout.tsx | 23 +- src/app/layout.tsx | 6 +- src/app/providers.tsx | 25 ++ .../admin/semi-finalists-content.tsx | 265 ++++++++++++++++++ src/components/admin/user-actions.tsx | 73 +++++ .../dashboard/semi-finalist-tracker.tsx | 18 +- src/components/settings/settings-content.tsx | 21 +- .../shared/impersonation-banner.tsx | 51 ++++ src/lib/auth.config.ts | 14 +- src/lib/auth.ts | 73 ++++- src/server/routers/dashboard.ts | 126 +++++++-- src/server/routers/settings.ts | 6 +- src/server/routers/user.ts | 82 ++++++ 18 files changed, 814 insertions(+), 74 deletions(-) create mode 100644 src/app/(admin)/admin/semi-finalists/page.tsx create mode 100644 src/components/admin/semi-finalists-content.tsx create mode 100644 src/components/shared/impersonation-banner.tsx diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx index 62fe8df..b934224 100644 --- a/src/app/(admin)/admin/dashboard-content.tsx +++ b/src/app/(admin)/admin/dashboard-content.tsx @@ -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 @@ -283,6 +284,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro byAward={semiFinalistStats.byAward} unactivatedProjects={semiFinalistStats.unactivatedProjects} editionId={editionId} + reminderThresholdDays={featureFlags?.accountReminderDays} /> )} diff --git a/src/app/(admin)/admin/semi-finalists/page.tsx b/src/app/(admin)/admin/semi-finalists/page.tsx new file mode 100644 index 0000000..ae842e4 --- /dev/null +++ b/src/app/(admin)/admin/semi-finalists/page.tsx @@ -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 ( +
+ No edition found. +
+ ) + } + + return +} diff --git a/src/app/(applicant)/layout.tsx b/src/app/(applicant)/layout.tsx index 875947c..a9085d3 100644 --- a/src/app/(applicant)/layout.tsx +++ b/src/app/(applicant)/layout.tsx @@ -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 ( diff --git a/src/app/(jury)/layout.tsx b/src/app/(jury)/layout.tsx index 824098e..68bda79 100644 --- a/src/app/(jury)/layout.tsx +++ b/src/app/(jury)/layout.tsx @@ -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 ( diff --git a/src/app/(mentor)/layout.tsx b/src/app/(mentor)/layout.tsx index 97d3c6b..279ca5e 100644 --- a/src/app/(mentor)/layout.tsx +++ b/src/app/(mentor)/layout.tsx @@ -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') } diff --git a/src/app/(observer)/layout.tsx b/src/app/(observer)/layout.tsx index 183b07c..034a73a 100644 --- a/src/app/(observer)/layout.tsx +++ b/src/app/(observer)/layout.tsx @@ -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 ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 23b1a95..5bdfdad 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - {children} + + + {children} + { + // 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 + }, }), ], }) diff --git a/src/components/admin/semi-finalists-content.tsx b/src/components/admin/semi-finalists-content.tsx new file mode 100644 index 0000000..0f38130 --- /dev/null +++ b/src/components/admin/semi-finalists-content.tsx @@ -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 = { + 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('all') + const [statusFilter, setStatusFilter] = useState('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 ( +
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + + +
+

+ Semi-Finalists +

+

+ {stats.total} projects · {stats.activated} fully activated · {stats.pending} pending +

+
+
+
+ + {/* Filters */} + + +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + +
+
+
+ + {/* Table */} + + + + + {filtered.length} project{filtered.length !== 1 ? 's' : ''} + + + + {filtered.length === 0 ? ( +

+ No semi-finalist projects match your filters. +

+ ) : ( +
+ + + + Project + Category + Country + Current Round + Team Members + Status + + + + {filtered.map((project) => ( + + + + {project.title} + + {project.teamName && ( +

{project.teamName}

+ )} +
+ + + {categoryLabels[project.category ?? ''] ?? project.category} + + + {project.country || '—'} + {project.currentRound} + + +
+ {project.teamMembers.map((tm, idx) => { + const cfg = statusConfig[tm.accountStatus] + const Icon = cfg.icon + return ( + + +
+ + + {tm.name || tm.email} + +
+
+ +

{tm.email}

+

+ {cfg.label} + {tm.lastLogin && ` · Last login: ${new Date(tm.lastLogin).toLocaleDateString()}`} +

+
+
+ ) + })} +
+
+
+ + {project.allActivated ? ( + + ) : ( + + )} + +
+ ))} +
+
+
+ )} +
+
+
+ ) +} diff --git a/src/components/admin/user-actions.tsx b/src/components/admin/user-actions.tsx index 55df33c..33bfc53 100644 --- a/src/components/admin/user-actions.tsx +++ b/src/components/admin/user-actions.tsx @@ -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 + {isSuperAdmin && session?.user?.id !== userId && ( + + {startImpersonation.isPending ? ( + + ) : ( + + )} + Login As + + )} {canChangeRole && ( @@ -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 + {isSuperAdmin && session?.user?.id !== userId && ( + + )} + + diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index bc0dc7d..f37a69d 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -383,7 +383,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin )} - + @@ -397,6 +397,25 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + + + + Account Reminders + + Configure when account setup reminders become appropriate + + + + + + + {isSuperAdmin && ( diff --git a/src/components/shared/impersonation-banner.tsx b/src/components/shared/impersonation-banner.tsx new file mode 100644 index 0000000..544f651 --- /dev/null +++ b/src/components/shared/impersonation-banner.tsx @@ -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 ( +
+ + Impersonating {session.user.name || session.user.email}{' '} + ({session.user.role.replace('_', ' ')}) + + +
+ ) +} diff --git a/src/lib/auth.config.ts b/src/lib/auth.config.ts index f57285d..e46281e 100644 --- a/src/lib/auth.config.ts +++ b/src/lib/auth.config.ts @@ -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)?.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 diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e5fc023..0279964 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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 }, diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts index 7a2cfde..8196f1c 100644 --- a/src/server/routers/dashboard.ts +++ b/src/server/routers/dashboard.ts @@ -586,15 +586,16 @@ export const dashboardRouter = router({ .query(async ({ ctx, input }) => { const { editionId } = input - // Find all projects with at least one PASSED state in this edition's rounds. - // Use the highest sortOrder PASSED round per project to avoid double-counting. - const passedStates = await ctx.prisma.projectRoundState.findMany({ + // Find projects whose LATEST terminal state (PASSED/REJECTED/WITHDRAWN) is PASSED. + // A project that passed round 1 but was rejected in round 2 is NOT a semi-finalist. + const terminalStates = await ctx.prisma.projectRoundState.findMany({ where: { - state: 'PASSED', + state: { in: ['PASSED', 'REJECTED', 'WITHDRAWN'] }, round: { competition: { programId: editionId } }, }, select: { projectId: true, + state: true, round: { select: { id: true, name: true, sortOrder: true } }, project: { select: { @@ -608,6 +609,7 @@ export const dashboardRouter = router({ id: true, email: true, name: true, + status: true, passwordHash: true, }, }, @@ -618,16 +620,17 @@ export const dashboardRouter = router({ }, }) - // Deduplicate: keep only the highest-sortOrder PASSED round per project - const projectMap = new Map() - for (const ps of passedStates) { - const existing = projectMap.get(ps.projectId) - if (!existing || ps.round.sortOrder > existing.round.sortOrder) { - projectMap.set(ps.projectId, ps) + // For each project, keep only the terminal state from the highest-sortOrder round + const projectMap = new Map() + for (const ts of terminalStates) { + const existing = projectMap.get(ts.projectId) + if (!existing || ts.round.sortOrder > existing.round.sortOrder) { + 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 const catMap = new Map() @@ -636,7 +639,7 @@ export const dashboardRouter = router({ if (!catMap.has(cat)) catMap.set(cat, { total: 0, accountsSet: 0, accountsNotSet: 0 }) const entry = catMap.get(cat)! 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++ else entry.accountsNotSet++ } @@ -676,7 +679,7 @@ export const dashboardRouter = router({ for (const pid of projectIds) { const ps = projectMap.get(pid) 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++ else accountsNotSet++ } @@ -684,15 +687,15 @@ export const dashboardRouter = router({ 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 - .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) => ({ projectId: ps.projectId, projectTitle: ps.project.title, category: ps.project.competitionCategory, 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), roundName: ps.round.name, })) @@ -700,6 +703,94 @@ export const dashboardRouter = router({ 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() + 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 * who haven't set their password yet. @@ -768,6 +859,7 @@ export const dashboardRouter = router({ id: true, email: true, name: true, + status: true, passwordHash: true, }, }, @@ -794,7 +886,7 @@ export const dashboardRouter = router({ for (const project of projects) { 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) { diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts index 6a03474..c9c0947 100644 --- a/src/server/routers/settings.ts +++ b/src/server/routers/settings.ts @@ -42,7 +42,7 @@ export const settingsRouter = router({ * These are non-sensitive settings that can be exposed to any user */ 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({ where: { key: 'whatsapp_enabled' }, }), @@ -58,6 +58,9 @@ export const settingsRouter = router({ ctx.prisma.systemSettings.findUnique({ where: { key: 'support_email' }, }), + ctx.prisma.systemSettings.findUnique({ + where: { key: 'account_reminder_days' }, + }), ]) return { @@ -66,6 +69,7 @@ export const settingsRouter = router({ learningHubExternal: learningHubExternal?.value === 'true', learningHubExternalUrl: learningHubExternalUrl?.value || '', supportEmail: supportEmail?.value || '', + accountReminderDays: parseInt(accountReminderDays?.value || '3', 10), } }), diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index d7094c9..e803563 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -1689,4 +1689,86 @@ export const userRouter = router({ 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 } + }), })