feat: router.back() navigation, read-only evaluation view, auth audit logging
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m53s

- Convert all Back buttons platform-wide (38 files) to use router.back()
  for natural browser-back behavior regardless of entry point
- Add read-only view for submitted evaluations in closed rounds with
  blue banner, disabled inputs, and contextual back navigation
- Add auth audit logs: MAGIC_LINK_SENT, PASSWORD_RESET_LINK_CLICKED,
  PASSWORD_RESET_LINK_EXPIRED, PASSWORD_RESET_LINK_INVALID
- Learning Hub links navigate in same window for all roles
- Update settings descriptions to reflect all-user scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 14:25:56 +01:00
parent a556732b46
commit a1e758bc39
44 changed files with 398 additions and 384 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { useParams } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
@@ -8,12 +8,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react'
import { ArrowLeft, CheckCircle2, Clock, Circle, Eye } from 'lucide-react'
import { toast } from 'sonner'
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
export default function JuryRoundDetailPage() {
const params = useParams()
const router = useRouter()
const roundId = params.roundId as string
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
@@ -38,11 +39,9 @@ export default function JuryRoundDetailPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={'/jury/competitions' as Route} aria-label="Back to competitions list">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
@@ -82,10 +81,13 @@ export default function JuryRoundDetailPage() {
const isDraft = assignment.evaluation?.status === 'DRAFT'
return (
<Link
<div
key={assignment.id}
href={`/jury/competitions/${roundId}/projects/${assignment.projectId}` as Route}
className="flex items-center justify-between p-4 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all"
role="button"
tabIndex={0}
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${assignment.projectId}`)}
onKeyDown={(e) => { if (e.key === 'Enter') router.push(`/jury/competitions/${roundId}/projects/${assignment.projectId}`) }}
className="flex items-center justify-between p-4 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all cursor-pointer"
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{assignment.project.title}</p>
@@ -97,12 +99,26 @@ export default function JuryRoundDetailPage() {
)}
</div>
</div>
<div className="flex items-center gap-3 ml-4">
<div className="flex items-center gap-2 ml-4">
{isCompleted ? (
<Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200">
<CheckCircle2 className="mr-1 h-3 w-3" />
Completed
</Badge>
<>
<Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200">
<CheckCircle2 className="mr-1 h-3 w-3" />
Completed
</Badge>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
asChild
onClick={(e) => e.stopPropagation()}
>
<Link href={`/jury/competitions/${roundId}/projects/${assignment.projectId}/evaluate` as Route}>
<Eye className="mr-1 h-3 w-3" />
View
</Link>
</Button>
</>
) : isDraft ? (
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-amber-200">
<Clock className="mr-1 h-3 w-3" />
@@ -115,7 +131,7 @@ export default function JuryRoundDetailPage() {
</Badge>
)}
</div>
</Link>
</div>
)
})}
</div>

View File

@@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { Badge } from '@/components/ui/badge'
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert } from 'lucide-react'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert, Lock } from 'lucide-react'
import { toast } from 'sonner'
import type { EvaluationConfig } from '@/types/competition-configs'
@@ -468,8 +468,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
// Check if round is active
const isRoundActive = round.status === 'ROUND_ACTIVE'
const isSubmittedEvaluation = existingEvaluation?.status === 'SUBMITTED'
if (!isRoundActive) {
// If round is not active and no submitted evaluation to view, block access
if (!isRoundActive && !isSubmittedEvaluation) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
@@ -502,8 +504,11 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
)
}
// COI gate: if COI is required, not yet declared, and we have an assignment
if (coiRequired && myAssignment && !coiLoading && !coiDeclared) {
// Read-only view for submitted evaluations in closed rounds
const isReadOnly = !isRoundActive && isSubmittedEvaluation
// COI gate: if COI is required, not yet declared, and we have an assignment (skip for read-only views)
if (coiRequired && !isReadOnly && myAssignment && !coiLoading && !coiDeclared) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
@@ -533,8 +538,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
)
}
// COI conflict declared — block evaluation
if (coiRequired && coiConflict) {
// COI conflict declared — block evaluation (skip for read-only views)
if (coiRequired && !isReadOnly && coiConflict) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
@@ -578,15 +583,22 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
{isReadOnly ? (
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
Back
</Button>
) : (
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
)}
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project
{isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
</h1>
<div className="flex items-center gap-2 mt-1">
<p className="text-muted-foreground">{project.title}</p>
@@ -606,21 +618,37 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div>
</div>
{isReadOnly && (
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
<CardContent className="flex items-start gap-3 p-4">
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">View-Only</p>
<p className="text-sm text-muted-foreground mt-1">
This evaluation has been submitted and the round is now closed. You are viewing a read-only copy of your submission.
</p>
</div>
</CardContent>
</Card>
)}
{/* Project Documents */}
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-3 p-4">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">Important Reminder</p>
<p className="text-sm text-muted-foreground mt-1">
Your evaluation will be used to assess this project. Please provide thoughtful and
constructive feedback. Your progress is automatically saved as a draft.
</p>
</div>
</CardContent>
</Card>
{!isReadOnly && (
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-3 p-4">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">Important Reminder</p>
<p className="text-sm text-muted-foreground mt-1">
Your evaluation will be used to assess this project. Please provide thoughtful and
constructive feedback. Your progress is automatically saved as a draft.
</p>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
@@ -673,12 +701,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<div className="flex gap-4">
<button
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, true)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
isReadOnly && 'opacity-60 cursor-default'
)}
>
<ThumbsUp className="mr-2 h-5 w-5" />
@@ -686,12 +716,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</button>
<button
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, false)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === false
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
: 'border-border hover:border-red-300 hover:bg-red-50/50'
: 'border-border hover:border-red-300 hover:bg-red-50/50',
isReadOnly && 'opacity-60 cursor-default'
)}
>
<ThumbsDown className="mr-2 h-5 w-5" />
@@ -718,12 +750,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<div className="flex gap-3">
<button
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, true)}
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
isReadOnly && 'opacity-60 cursor-default'
)}
>
<ThumbsUp className="mr-2 h-4 w-4" />
@@ -731,12 +765,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</button>
<button
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, false)}
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === false
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
: 'border-border hover:border-red-300 hover:bg-red-50/50'
: 'border-border hover:border-red-300 hover:bg-red-50/50',
isReadOnly && 'opacity-60 cursor-default'
)}
>
<ThumbsDown className="mr-2 h-4 w-4" />
@@ -766,6 +802,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
placeholder={criterion.placeholder || 'Enter your response...'}
rows={4}
maxLength={criterion.maxLength}
disabled={isReadOnly}
/>
<p className="text-xs text-muted-foreground text-right">
{currentValue.length}/{criterion.maxLength}
@@ -807,6 +844,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
value={[sliderValue]}
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
className="flex-1"
disabled={isReadOnly}
/>
<span className="text-xs text-muted-foreground w-4">{max}</span>
</div>
@@ -816,6 +854,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<button
key={num}
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, num)}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
@@ -823,7 +862,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
? 'bg-primary text-primary-foreground'
: displayValue !== undefined && displayValue > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80'
: 'bg-muted hover:bg-muted/80',
isReadOnly && 'cursor-default'
)}
>
{num}
@@ -856,6 +896,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
value={[globalScore ? parseInt(globalScore, 10) : 5]}
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
className="flex-1"
disabled={isReadOnly}
/>
<span className="text-xs text-muted-foreground">10</span>
</div>
@@ -866,6 +907,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<button
key={num}
type="button"
disabled={isReadOnly}
onClick={() => handleGlobalScoreChange(num.toString())}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
@@ -873,7 +915,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
? 'bg-primary text-primary-foreground'
: current > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80'
: 'bg-muted hover:bg-muted/80',
isReadOnly && 'cursor-default'
)}
>
{num}
@@ -890,7 +933,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Label>
Decision <span className="text-destructive">*</span>
</Label>
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')}>
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')} disabled={isReadOnly}>
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
<RadioGroupItem value="accept" id="accept" />
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
@@ -921,6 +964,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
onChange={(e) => handleFeedbackChange(e.target.value)}
placeholder="Provide your feedback on the project..."
rows={8}
disabled={isReadOnly}
/>
{requireFeedback && (
<p className="text-xs text-muted-foreground">
@@ -931,32 +975,44 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</CardContent>
</Card>
<div className="flex items-center justify-between flex-wrap gap-4">
<Button
variant="outline"
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${projectId}` as Route)}
>
Cancel
</Button>
<div className="flex gap-3">
{isReadOnly ? (
<div className="flex items-center">
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={autosaveMutation.isPending || submitMutation.isPending}
onClick={() => router.back()}
>
<Save className="mr-2 h-4 w-4" />
{autosaveMutation.isPending ? 'Saving...' : 'Save Draft'}
</Button>
<Button
onClick={handleSubmit}
disabled={submitMutation.isPending || isSubmitting}
className="bg-brand-blue hover:bg-brand-blue-light"
>
<Send className="mr-2 h-4 w-4" />
{submitMutation.isPending ? 'Submitting...' : 'Submit Evaluation'}
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between flex-wrap gap-4">
<Button
variant="outline"
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${projectId}` as Route)}
>
Cancel
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={autosaveMutation.isPending || submitMutation.isPending}
>
<Save className="mr-2 h-4 w-4" />
{autosaveMutation.isPending ? 'Saving...' : 'Save Draft'}
</Button>
<Button
onClick={handleSubmit}
disabled={submitMutation.isPending || isSubmitting}
className="bg-brand-blue hover:bg-brand-blue-light"
>
<Send className="mr-2 h-4 w-4" />
{submitMutation.isPending ? 'Submitting...' : 'Submit Evaluation'}
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useParams } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
@@ -13,6 +13,7 @@ import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
export default function JuryProjectDetailPage() {
const params = useParams()
const router = useRouter()
const roundId = params.roundId as string
const projectId = params.projectId as string
@@ -42,11 +43,9 @@ export default function JuryProjectDetailPage() {
if (!project) {
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
@@ -61,11 +60,9 @@ export default function JuryProjectDetailPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</div>

View File

@@ -2,6 +2,7 @@
import Link from 'next/link'
import type { Route } from 'next'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -20,6 +21,7 @@ import {
import { formatDateOnly, formatEnumLabel } from '@/lib/utils'
export default function JuryAssignmentsPage() {
const router = useRouter()
const { data: assignments, isLoading } = trpc.assignment.myAssignments.useQuery({})
if (isLoading) {
@@ -58,11 +60,9 @@ export default function JuryAssignmentsPage() {
Projects assigned to you for evaluation
</p>
</div>
<Button variant="ghost" size="sm" asChild className="hidden md:inline-flex">
<Link href={'/jury' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
<Button variant="ghost" size="sm" onClick={() => router.back()} className="hidden md:inline-flex">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</div>