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) {
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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
|
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)
|
||||||
const user = await prisma.user.findUnique({
|
if (!isImpersonating) {
|
||||||
where: { id: session.user.id },
|
const user = await prisma.user.findUnique({
|
||||||
select: { onboardingCompletedAt: true },
|
where: { id: session.user.id },
|
||||||
})
|
select: { onboardingCompletedAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect('/login')
|
redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.onboardingCompletedAt) {
|
if (!user.onboardingCompletedAt) {
|
||||||
redirect('/onboarding')
|
redirect('/onboarding')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,20 +11,22 @@ 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)
|
||||||
const user = await prisma.user.findUnique({
|
if (!isImpersonating) {
|
||||||
where: { id: session.user.id },
|
const user = await prisma.user.findUnique({
|
||||||
select: { onboardingCompletedAt: true },
|
where: { id: session.user.id },
|
||||||
})
|
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 (
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,19 +12,22 @@ 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)
|
||||||
const user = await prisma.user.findUnique({
|
if (!isImpersonating) {
|
||||||
where: { id: session.user.id },
|
const user = await prisma.user.findUnique({
|
||||||
select: { onboardingCompletedAt: true },
|
where: { id: session.user.id },
|
||||||
})
|
select: { onboardingCompletedAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect('/login')
|
redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.onboardingCompletedAt) {
|
if (!user.onboardingCompletedAt) {
|
||||||
redirect('/onboarding')
|
redirect('/onboarding')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
265
src/components/admin/semi-finalists-content.tsx
Normal file
265
src/components/admin/semi-finalists-content.tsx
Normal 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 · {stats.activated} fully activated · {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
<Badge variant="outline" className="text-xs">
|
<div className="flex items-center gap-2">
|
||||||
{totalActivated}/{totalProjects} activated
|
<Badge variant="outline" className="text-xs">
|
||||||
</Badge>
|
{totalActivated}/{totalProjects} activated
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
51
src/components/shared/impersonation-banner.tsx
Normal file
51
src/components/shared/impersonation-banner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,16 +230,66 @@ 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) {
|
||||||
const dbUser = await prisma.user.findUnique({
|
// Start impersonation
|
||||||
where: { id: token.id as string },
|
if (session.impersonate && typeof session.impersonate === 'string') {
|
||||||
select: { role: true, roles: true, mustSetPassword: true },
|
// Only SUPER_ADMIN can impersonate (defense-in-depth)
|
||||||
})
|
if (token.role === 'SUPER_ADMIN' && !token.impersonating) {
|
||||||
if (dbUser) {
|
const targetUser = await prisma.user.findUnique({
|
||||||
token.role = dbUser.role
|
where: { id: session.impersonate },
|
||||||
token.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
|
select: { id: true, email: true, name: true, role: true, roles: true, status: true },
|
||||||
token.mustSetPassword = dbUser.mustSetPassword
|
})
|
||||||
|
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.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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user