fix: security hardening — block self-registration, SSE auth, audit logging fixes
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Security fixes: - Block self-registration via magic link (PrismaAdapter createUser throws) - Magic links only sent to existing ACTIVE users (prevents enumeration) - signIn callback rejects non-existent users (defense-in-depth) - Change schema default role from JURY_MEMBER to APPLICANT - Add authentication to live-voting SSE stream endpoint - Fix false FILE_OPENED/FILE_DOWNLOADED audit events on page load (remove purpose from eagerly pre-fetched URL queries) Bug fixes: - Fix impersonation skeleton screen on applicant dashboard - Fix onboarding redirect loop in auth layout Observer dashboard redesign (Steps 1-6): - Clickable round pipeline with selected round highlighting - Round-type-specific dashboard panels (intake, filtering, evaluation, submission, mentoring, live final, deliberation) - Enhanced activity feed with server-side humanization - Previous round comparison section - New backend queries for round-specific analytics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
164
src/components/observer/dashboard/live-final-panel.tsx
Normal file
164
src/components/observer/dashboard/live-final-panel.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { Radio, Users, Trophy, Eye, EyeOff } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SESSION_STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; pulse?: boolean }> = {
|
||||
NOT_STARTED: { label: 'Not Started', color: 'text-slate-500', bg: 'bg-slate-100 dark:bg-slate-800' },
|
||||
IN_PROGRESS: { label: 'In Progress', color: 'text-emerald-600', bg: 'bg-emerald-50 dark:bg-emerald-900/20', pulse: true },
|
||||
PAUSED: { label: 'Paused', color: 'text-amber-600', bg: 'bg-amber-50 dark:bg-amber-900/20' },
|
||||
COMPLETED: { label: 'Completed', color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||
}
|
||||
|
||||
export function LiveFinalPanel({ roundId }: { roundId: string }) {
|
||||
const { data: liveDash, isLoading } = trpc.analytics.getLiveFinalDashboard.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 10_000 },
|
||||
)
|
||||
|
||||
const { data: roundStats } = trpc.analytics.getRoundTypeStats.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
const stats = roundStats?.stats as {
|
||||
sessionStatus: string
|
||||
voteCount: number
|
||||
} | undefined
|
||||
|
||||
const sessionStatus = liveDash?.sessionStatus ?? stats?.sessionStatus ?? 'NOT_STARTED'
|
||||
const statusConfig = SESSION_STATUS_CONFIG[sessionStatus] ?? SESSION_STATUS_CONFIG.NOT_STARTED
|
||||
|
||||
const jurors = liveDash?.jurors ?? []
|
||||
const votedCount = jurors.filter((j: any) => j.hasVoted).length
|
||||
const standings = liveDash?.standings ?? []
|
||||
const visibility = liveDash?.observerScoreVisibility ?? 'after_completion'
|
||||
const scoresVisible = visibility === 'realtime'
|
||||
|| (visibility === 'after_completion' && sessionStatus === 'COMPLETED')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Session Status Card */}
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 rounded-lg" />
|
||||
) : (
|
||||
<Card className={cn('p-5', statusConfig.bg)}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<Radio className={cn('h-8 w-8', statusConfig.color)} />
|
||||
{statusConfig.pulse && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-3 w-3 rounded-full bg-emerald-500 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn('text-lg font-semibold', statusConfig.color)}>
|
||||
{statusConfig.label}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{liveDash?.voteCount ?? stats?.voteCount ?? 0} votes cast
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vote Count */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card className="p-3 text-center">
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{liveDash?.voteCount ?? stats?.voteCount ?? 0}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Total Votes</p>
|
||||
</Card>
|
||||
<Card className="p-3 text-center">
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{votedCount}/{jurors.length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Jurors Voted</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Juror Participation */}
|
||||
{jurors.length > 0 && (
|
||||
<AnimatedCard index={1}>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Users className="h-4 w-4 text-violet-500" />
|
||||
Juror Participation
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1.5 max-h-[250px] overflow-y-auto">
|
||||
{jurors.map((j: any) => (
|
||||
<div key={j.id} className="flex items-center justify-between text-sm py-1">
|
||||
<span className="truncate">{j.name}</span>
|
||||
<Badge
|
||||
variant={j.hasVoted ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'text-xs',
|
||||
j.hasVoted && 'bg-emerald-500 hover:bg-emerald-600',
|
||||
)}
|
||||
>
|
||||
{j.hasVoted ? 'Voted' : 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Standings / Score Visibility */}
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Trophy className="h-4 w-4 text-amber-500" />
|
||||
Standings
|
||||
{scoresVisible ? (
|
||||
<Eye className="h-3.5 w-3.5 text-emerald-500 ml-auto" />
|
||||
) : (
|
||||
<EyeOff className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{scoresVisible && standings.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{standings.map((s: any, i: number) => (
|
||||
<div key={s.projectId} className="flex items-center justify-between text-sm py-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-muted-foreground tabular-nums font-medium w-5 text-right">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<span className="truncate">{s.projectTitle}</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="tabular-nums shrink-0">
|
||||
{typeof s.score === 'number' ? s.score.toFixed(1) : s.score}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6">
|
||||
<EyeOff className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{sessionStatus === 'COMPLETED'
|
||||
? 'Scores are hidden by admin configuration.'
|
||||
: 'Scores will be revealed when voting completes.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user