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:
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 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
|
||||
</Link>
|
||||
</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 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={updateRoles.isPending}>
|
||||
@@ -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
|
||||
</Link>
|
||||
</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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Users,
|
||||
Send,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
AlertCircle,
|
||||
Trophy,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
@@ -44,6 +47,7 @@ type SemiFinalistTrackerProps = {
|
||||
byAward: AwardStat[]
|
||||
unactivatedProjects: UnactivatedProject[]
|
||||
editionId: string
|
||||
reminderThresholdDays?: number
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
@@ -57,6 +61,7 @@ export function SemiFinalistTracker({
|
||||
byAward,
|
||||
unactivatedProjects,
|
||||
editionId,
|
||||
reminderThresholdDays = 3,
|
||||
}: SemiFinalistTrackerProps) {
|
||||
const utils = trpc.useUtils()
|
||||
const sendReminders = trpc.dashboard.sendAccountReminders.useMutation({
|
||||
@@ -97,9 +102,16 @@ export function SemiFinalistTracker({
|
||||
<Users className="h-4 w-4 text-brand-blue" />
|
||||
Semi-Finalist Tracker
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{totalActivated}/{totalProjects} activated
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -383,7 +383,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="notifications">
|
||||
<TabsContent value="notifications" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -397,6 +397,25 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user