From a1e758bc3918d7bce4d4b2b283805ee3804d6ca5 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 6 Mar 2026 14:25:56 +0100 Subject: [PATCH] feat: router.back() navigation, read-only evaluation view, auth audit logging - 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 --- .../(admin)/admin/awards/[id]/edit/page.tsx | 8 +- src/app/(admin)/admin/awards/[id]/page.tsx | 8 +- src/app/(admin)/admin/awards/new/page.tsx | 8 +- .../(admin)/admin/juries/[groupId]/page.tsx | 14 +- src/app/(admin)/admin/learning/[id]/page.tsx | 17 +- src/app/(admin)/admin/learning/new/page.tsx | 9 +- src/app/(admin)/admin/members/[id]/page.tsx | 16 +- src/app/(admin)/admin/members/invite/page.tsx | 10 +- .../(admin)/admin/messages/templates/page.tsx | 11 +- src/app/(admin)/admin/partners/[id]/page.tsx | 8 +- src/app/(admin)/admin/partners/new/page.tsx | 8 +- .../(admin)/admin/programs/[id]/edit/page.tsx | 8 +- .../admin/programs/[id]/mentorship/page.tsx | 12 +- src/app/(admin)/admin/programs/new/page.tsx | 8 +- .../(admin)/admin/projects/[id]/edit/page.tsx | 20 +-- .../admin/projects/[id]/mentor/page.tsx | 11 +- src/app/(admin)/admin/projects/[id]/page.tsx | 22 ++- .../admin/projects/bulk-upload/page.tsx | 9 +- .../(admin)/admin/projects/import/page.tsx | 8 +- src/app/(admin)/admin/projects/new/page.tsx | 8 +- .../(admin)/admin/rounds/[roundId]/page.tsx | 23 +-- src/app/(admin)/admin/settings/tags/page.tsx | 11 +- .../(admin)/admin/settings/webhooks/page.tsx | 11 +- .../applicant/competition/page.tsx | 37 +--- .../applicant/resources/[id]/page.tsx | 20 +-- src/app/(jury)/jury/awards/[id]/page.tsx | 11 +- .../jury/competitions/[roundId]/page.tsx | 48 ++++-- .../projects/[projectId]/evaluate/page.tsx | 160 ++++++++++++------ .../[roundId]/projects/[projectId]/page.tsx | 19 +-- src/app/(jury)/jury/competitions/page.tsx | 10 +- src/app/(jury)/jury/learning/[id]/page.tsx | 20 +-- src/app/(jury)/jury/page.tsx | 2 +- .../(mentor)/mentor/projects/[id]/page.tsx | 24 ++- .../(mentor)/mentor/resources/[id]/page.tsx | 20 +-- .../mentor/workspace/[projectId]/page.tsx | 21 +-- src/app/(mentor)/mentor/workspace/page.tsx | 10 +- .../[id]/submission-detail-client.tsx | 19 +-- .../(public)/my-submission/[id]/team/page.tsx | 6 +- .../admin/semi-finalists-content.tsx | 10 +- src/components/layouts/role-nav.tsx | 4 - .../observer/observer-project-detail.tsx | 14 +- src/components/settings/settings-content.tsx | 4 +- src/lib/auth.ts | 13 +- src/server/routers/user.ts | 42 +++++ 44 files changed, 398 insertions(+), 384 deletions(-) diff --git a/src/app/(admin)/admin/awards/[id]/edit/page.tsx b/src/app/(admin)/admin/awards/[id]/edit/page.tsx index d1eaa74..0ec34f3 100644 --- a/src/app/(admin)/admin/awards/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/edit/page.tsx @@ -122,11 +122,9 @@ export default function EditAwardPage({ return (
-
diff --git a/src/app/(admin)/admin/awards/[id]/page.tsx b/src/app/(admin)/admin/awards/[id]/page.tsx index 8c4e9cd..8bce89a 100644 --- a/src/app/(admin)/admin/awards/[id]/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/page.tsx @@ -663,11 +663,9 @@ export default function AwardDetailPage({
{/* Header */}
-
diff --git a/src/app/(admin)/admin/awards/new/page.tsx b/src/app/(admin)/admin/awards/new/page.tsx index 4cf8f6e..7644db8 100644 --- a/src/app/(admin)/admin/awards/new/page.tsx +++ b/src/app/(admin)/admin/awards/new/page.tsx @@ -69,11 +69,9 @@ export default function CreateAwardPage() { return (
-
diff --git a/src/app/(admin)/admin/juries/[groupId]/page.tsx b/src/app/(admin)/admin/juries/[groupId]/page.tsx index 9d4c01d..24a94de 100644 --- a/src/app/(admin)/admin/juries/[groupId]/page.tsx +++ b/src/app/(admin)/admin/juries/[groupId]/page.tsx @@ -194,10 +194,8 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps

The requested jury group could not be found.

-
@@ -212,13 +210,11 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
diff --git a/src/app/(admin)/admin/learning/[id]/page.tsx b/src/app/(admin)/admin/learning/[id]/page.tsx index 0efd885..7200266 100644 --- a/src/app/(admin)/admin/learning/[id]/page.tsx +++ b/src/app/(admin)/admin/learning/[id]/page.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' -import Link from 'next/link' import dynamic from 'next/dynamic' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -257,11 +256,9 @@ export default function EditLearningResourcePage() { The resource you're looking for does not exist. -
) @@ -271,11 +268,9 @@ export default function EditLearningResourcePage() {
{/* Sticky toolbar */}
-
diff --git a/src/app/(admin)/admin/learning/new/page.tsx b/src/app/(admin)/admin/learning/new/page.tsx index 7dcc904..b3a17ea 100644 --- a/src/app/(admin)/admin/learning/new/page.tsx +++ b/src/app/(admin)/admin/learning/new/page.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react' import { useRouter } from 'next/navigation' -import Link from 'next/link' import dynamic from 'next/dynamic' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -165,11 +164,9 @@ export default function NewLearningResourcePage() {
{/* Sticky toolbar */}
-
diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx index e84f6b6..e9718f2 100644 --- a/src/app/(admin)/admin/members/[id]/page.tsx +++ b/src/app/(admin)/admin/members/[id]/page.tsx @@ -224,11 +224,9 @@ export default function MemberDetailPage() { {error?.message || 'The member you\'re looking for does not exist.'} -
) @@ -239,11 +237,9 @@ export default function MemberDetailPage() { return (
{/* Back nav */} - {/* Header Hero */} diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx index 54ec3e2..d643fc2 100644 --- a/src/app/(admin)/admin/members/invite/page.tsx +++ b/src/app/(admin)/admin/members/invite/page.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react' import Link from 'next/link' +import { useRouter } from 'next/navigation' import Papa from 'papaparse' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -257,6 +258,7 @@ function TagPicker({ } export default function MemberInvitePage() { + const router = useRouter() const [step, setStep] = useState('input') const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual') const [rows, setRows] = useState([createEmptyRow()]) @@ -1044,11 +1046,9 @@ export default function MemberInvitePage() { return (
-
diff --git a/src/app/(admin)/admin/messages/templates/page.tsx b/src/app/(admin)/admin/messages/templates/page.tsx index dc937ce..bf0cd85 100644 --- a/src/app/(admin)/admin/messages/templates/page.tsx +++ b/src/app/(admin)/admin/messages/templates/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import Link from 'next/link' +import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Card, @@ -83,6 +83,7 @@ const defaultForm: TemplateFormData = { } export default function MessageTemplatesPage() { + const router = useRouter() const [dialogOpen, setDialogOpen] = useState(false) const [editingId, setEditingId] = useState(null) const [deleteId, setDeleteId] = useState(null) @@ -183,11 +184,9 @@ export default function MessageTemplatesPage() {
{/* Header */}
-
diff --git a/src/app/(admin)/admin/partners/[id]/page.tsx b/src/app/(admin)/admin/partners/[id]/page.tsx index 7396ad9..364575f 100644 --- a/src/app/(admin)/admin/partners/[id]/page.tsx +++ b/src/app/(admin)/admin/partners/[id]/page.tsx @@ -135,11 +135,9 @@ export default function EditPartnerPage() {
- - - +

Edit Partner

diff --git a/src/app/(admin)/admin/partners/new/page.tsx b/src/app/(admin)/admin/partners/new/page.tsx index b038744..5727a8e 100644 --- a/src/app/(admin)/admin/partners/new/page.tsx +++ b/src/app/(admin)/admin/partners/new/page.tsx @@ -66,11 +66,9 @@ export default function NewPartnerPage() { return (

- - - +

Add Partner

diff --git a/src/app/(admin)/admin/programs/[id]/edit/page.tsx b/src/app/(admin)/admin/programs/[id]/edit/page.tsx index 88e6e7a..1fc1611 100644 --- a/src/app/(admin)/admin/programs/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/programs/[id]/edit/page.tsx @@ -134,11 +134,9 @@ export default function EditProgramPage() {

- - - +

Edit Program

diff --git a/src/app/(admin)/admin/programs/[id]/mentorship/page.tsx b/src/app/(admin)/admin/programs/[id]/mentorship/page.tsx index ceb7545..277a999 100644 --- a/src/app/(admin)/admin/programs/[id]/mentorship/page.tsx +++ b/src/app/(admin)/admin/programs/[id]/mentorship/page.tsx @@ -1,8 +1,7 @@ 'use client' import { useState } from 'react' -import { useParams } from 'next/navigation' -import Link from 'next/link' +import { useParams, useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Card, @@ -68,6 +67,7 @@ const defaultMilestoneForm: MilestoneFormData = { export default function MentorshipMilestonesPage() { const params = useParams() + const router = useRouter() const programId = params.id as string const [dialogOpen, setDialogOpen] = useState(false) @@ -184,11 +184,9 @@ export default function MentorshipMilestonesPage() {

{/* Header */}
-
diff --git a/src/app/(admin)/admin/programs/new/page.tsx b/src/app/(admin)/admin/programs/new/page.tsx index 00905f5..34cc178 100644 --- a/src/app/(admin)/admin/programs/new/page.tsx +++ b/src/app/(admin)/admin/programs/new/page.tsx @@ -56,11 +56,9 @@ export default function NewProgramPage() { return (
- - - +

Create Program

diff --git a/src/app/(admin)/admin/projects/[id]/edit/page.tsx b/src/app/(admin)/admin/projects/[id]/edit/page.tsx index 1dedafe..903d0ff 100644 --- a/src/app/(admin)/admin/projects/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/edit/page.tsx @@ -300,19 +300,17 @@ function EditProjectContent({ projectId }: { projectId: string }) { if (!project) { return (

-

Project Not Found

-
@@ -330,11 +328,9 @@ function EditProjectContent({ projectId }: { projectId: string }) {
{/* Header */}
-
diff --git a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx index 6ecedfb..0bd2402 100644 --- a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx @@ -1,7 +1,7 @@ 'use client' import { Suspense, use, useState } from 'react' -import Link from 'next/link' +import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { @@ -46,6 +46,7 @@ interface MentorSuggestion { } function MentorAssignmentContent({ projectId }: { projectId: string }) { + const router = useRouter() const [selectedMentorId, setSelectedMentorId] = useState(null) const utils = trpc.useUtils() @@ -128,11 +129,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
{/* Header */}
-
diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index a02c368..1b6ead5 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -3,6 +3,7 @@ import { Suspense, use, useState } from 'react' import Link from 'next/link' import type { Route } from 'next' +import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Card, @@ -102,6 +103,7 @@ const evalStatusColors: Record -

Project Not Found

-
@@ -223,11 +223,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{/* Header */}
-
diff --git a/src/app/(admin)/admin/projects/bulk-upload/page.tsx b/src/app/(admin)/admin/projects/bulk-upload/page.tsx index a66a98d..6db56a3 100644 --- a/src/app/(admin)/admin/projects/bulk-upload/page.tsx +++ b/src/app/(admin)/admin/projects/bulk-upload/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useCallback, useRef, useEffect, useMemo } from 'react' -import Link from 'next/link' +import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { @@ -62,6 +62,7 @@ type UploadState = { type UploadMap = Record export default function BulkUploadPage() { + const router = useRouter() const [roundId, setRoundId] = useState('') const [search, setSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') @@ -296,10 +297,8 @@ export default function BulkUploadPage() {
{/* Header */}
-

Bulk Document Upload

diff --git a/src/app/(admin)/admin/projects/import/page.tsx b/src/app/(admin)/admin/projects/import/page.tsx index 797283f..0055aed 100644 --- a/src/app/(admin)/admin/projects/import/page.tsx +++ b/src/app/(admin)/admin/projects/import/page.tsx @@ -59,11 +59,9 @@ function ImportPageContent() {
{/* Header */}
-
diff --git a/src/app/(admin)/admin/projects/new/page.tsx b/src/app/(admin)/admin/projects/new/page.tsx index a051c28..93ee9a5 100644 --- a/src/app/(admin)/admin/projects/new/page.tsx +++ b/src/app/(admin)/admin/projects/new/page.tsx @@ -246,11 +246,9 @@ function NewProjectPageContent() {
{/* Header */}
-
diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 102d8af..1886554 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useMemo, useCallback, useRef, useEffect } from 'react' -import { useParams, useSearchParams } 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' @@ -152,8 +152,7 @@ const stateColors: Record = Object.fromEntries( export default function RoundDetailPage() { const params = useParams() const roundId = params.roundId as string - const searchParams = useSearchParams() - const backUrl = searchParams.get('from') + const router = useRouter() const [config, setConfig] = useState>({}) const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') @@ -546,11 +545,9 @@ export default function RoundDetailPage() { return (
- - - +

Round Not Found

This round does not exist.

@@ -622,12 +619,10 @@ export default function RoundDetailPage() { >
- - - +
{/* 4.6 Inline-editable round name */} diff --git a/src/app/(admin)/admin/settings/tags/page.tsx b/src/app/(admin)/admin/settings/tags/page.tsx index bd4cfc0..49db1ea 100644 --- a/src/app/(admin)/admin/settings/tags/page.tsx +++ b/src/app/(admin)/admin/settings/tags/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import Link from 'next/link' +import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -212,6 +212,7 @@ function SortableTagRow({ } export default function TagsSettingsPage() { + const router = useRouter() const utils = trpc.useUtils() const [isCreateOpen, setIsCreateOpen] = useState(false) const [editingTag, setEditingTag] = useState(null) @@ -384,11 +385,9 @@ export default function TagsSettingsPage() {
{/* Header */}
-
diff --git a/src/app/(admin)/admin/settings/webhooks/page.tsx b/src/app/(admin)/admin/settings/webhooks/page.tsx index 7df2a53..64a5b27 100644 --- a/src/app/(admin)/admin/settings/webhooks/page.tsx +++ b/src/app/(admin)/admin/settings/webhooks/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import Link from 'next/link' +import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Card, @@ -86,6 +86,7 @@ const defaultForm: WebhookFormData = { } export default function WebhooksPage() { + const router = useRouter() const [dialogOpen, setDialogOpen] = useState(false) const [editingId, setEditingId] = useState(null) const [deleteId, setDeleteId] = useState(null) @@ -254,11 +255,9 @@ export default function WebhooksPage() {
{/* Header */}
-
diff --git a/src/app/(applicant)/applicant/competition/page.tsx b/src/app/(applicant)/applicant/competition/page.tsx index eb68aba..f0ea5d2 100644 --- a/src/app/(applicant)/applicant/competition/page.tsx +++ b/src/app/(applicant)/applicant/competition/page.tsx @@ -1,16 +1,16 @@ 'use client' import { useSession } from 'next-auth/react' -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 { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline' -import { ArrowLeft, FileText, Calendar } from 'lucide-react' +import { ArrowLeft, FileText } from 'lucide-react' export default function ApplicantCompetitionPage() { + const router = useRouter() const { data: session } = useSession() const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, { enabled: !!session, @@ -36,11 +36,9 @@ export default function ApplicantCompetitionPage() { Track your progress through competition rounds

-
@@ -61,29 +59,6 @@ export default function ApplicantCompetitionPage() {
- - - - - Quick Actions - - - - - {myProject?.openRounds && myProject.openRounds.length > 0 && ( -

- {myProject.openRounds.length} submission window - {myProject.openRounds.length !== 1 ? 's' : ''} currently open -

- )} -
-
- Timeline Info diff --git a/src/app/(applicant)/applicant/resources/[id]/page.tsx b/src/app/(applicant)/applicant/resources/[id]/page.tsx index 3e93d10..a14e172 100644 --- a/src/app/(applicant)/applicant/resources/[id]/page.tsx +++ b/src/app/(applicant)/applicant/resources/[id]/page.tsx @@ -1,8 +1,7 @@ 'use client' import { useEffect } from 'react' -import { useParams } from 'next/navigation' -import Link from 'next/link' +import { useParams, useRouter } from 'next/navigation' import dynamic from 'next/dynamic' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -27,6 +26,7 @@ const ResourceRenderer = dynamic( export default function ApplicantResourceDetailPage() { const params = useParams() + const router = useRouter() const resourceId = params.id as string const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId }) @@ -73,11 +73,9 @@ export default function ApplicantResourceDetailPage() { This resource may have been removed or you don't have access. -
) @@ -87,11 +85,9 @@ export default function ApplicantResourceDetailPage() {
{/* Header */}
-
{resource.externalUrl && ( diff --git a/src/app/(jury)/jury/awards/[id]/page.tsx b/src/app/(jury)/jury/awards/[id]/page.tsx index 433e88d..e93cc9f 100644 --- a/src/app/(jury)/jury/awards/[id]/page.tsx +++ b/src/app/(jury)/jury/awards/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client' import { use, useState } from 'react' -import Link from 'next/link' +import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { @@ -29,6 +29,7 @@ export default function JuryAwardVotingPage({ params: Promise<{ id: string }> }) { const { id: awardId } = use(params) + const router = useRouter() const utils = trpc.useUtils() const { data, isLoading, refetch } = @@ -120,11 +121,9 @@ export default function JuryAwardVotingPage({ return (
-
diff --git a/src/app/(jury)/jury/competitions/[roundId]/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/page.tsx index ab0e13c..c7645ae 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/page.tsx @@ -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 (
-

@@ -82,10 +81,13 @@ export default function JuryRoundDetailPage() { const isDraft = assignment.evaluation?.status === 'DRAFT' return ( - 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" >

{assignment.project.title}

@@ -97,12 +99,26 @@ export default function JuryRoundDetailPage() { )}

-
+
{isCompleted ? ( - - - Completed - + <> + + + Completed + + + ) : isDraft ? ( @@ -115,7 +131,7 @@ export default function JuryRoundDetailPage() { )}
- +
) })}
diff --git a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx index bb2a502..8417b03 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx @@ -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 (
@@ -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 (
@@ -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 (
@@ -578,15 +583,22 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { return (
- + Back + + ) : ( + + )}

- Evaluate Project + {isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}

{project.title}

@@ -606,21 +618,37 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
+ {isReadOnly && ( + + + +
+

View-Only

+

+ This evaluation has been submitted and the round is now closed. You are viewing a read-only copy of your submission. +

+
+
+
+ )} + {/* Project Documents */} - - - -
-

Important Reminder

-

- Your evaluation will be used to assess this project. Please provide thoughtful and - constructive feedback. Your progress is automatically saved as a draft. -

-
-
-
+ {!isReadOnly && ( + + + +
+

Important Reminder

+

+ Your evaluation will be used to assess this project. Please provide thoughtful and + constructive feedback. Your progress is automatically saved as a draft. +

+
+
+
+ )} @@ -673,12 +701,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
@@ -816,6 +854,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
@@ -866,6 +907,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { -
+ {isReadOnly ? ( +
-
-
+ ) : ( +
+ +
+ + +
+
+ )}
) } diff --git a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/page.tsx index 4ffcd94..b08ee77 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/page.tsx @@ -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 (
- @@ -61,11 +60,9 @@ export default function JuryProjectDetailPage() { return (
-
diff --git a/src/app/(jury)/jury/competitions/page.tsx b/src/app/(jury)/jury/competitions/page.tsx index 33cbea7..6f94dee 100644 --- a/src/app/(jury)/jury/competitions/page.tsx +++ b/src/app/(jury)/jury/competitions/page.tsx @@ -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

-
diff --git a/src/app/(jury)/jury/learning/[id]/page.tsx b/src/app/(jury)/jury/learning/[id]/page.tsx index d9549ed..4fef20d 100644 --- a/src/app/(jury)/jury/learning/[id]/page.tsx +++ b/src/app/(jury)/jury/learning/[id]/page.tsx @@ -1,8 +1,7 @@ 'use client' import { useEffect } from 'react' -import { useParams } from 'next/navigation' -import Link from 'next/link' +import { useParams, useRouter } from 'next/navigation' import dynamic from 'next/dynamic' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -27,6 +26,7 @@ const ResourceRenderer = dynamic( export default function JuryResourceDetailPage() { const params = useParams() + const router = useRouter() const resourceId = params.id as string const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId }) @@ -73,11 +73,9 @@ export default function JuryResourceDetailPage() { This resource may have been removed or you don't have access. -
) @@ -87,11 +85,9 @@ export default function JuryResourceDetailPage() {
{/* Header */}
-
{resource.externalUrl && ( diff --git a/src/app/(jury)/jury/page.tsx b/src/app/(jury)/jury/page.tsx index ac97d4d..6e5fb8e 100644 --- a/src/app/(jury)/jury/page.tsx +++ b/src/app/(jury)/jury/page.tsx @@ -623,7 +623,7 @@ async function JuryDashboardContent() {
- Stage Summary + Round Summary
diff --git a/src/app/(mentor)/mentor/projects/[id]/page.tsx b/src/app/(mentor)/mentor/projects/[id]/page.tsx index 659ca52..fa50e56 100644 --- a/src/app/(mentor)/mentor/projects/[id]/page.tsx +++ b/src/app/(mentor)/mentor/projects/[id]/page.tsx @@ -1,8 +1,7 @@ 'use client' import { Suspense, use, useState, useEffect } from 'react' -import Link from 'next/link' -import type { Route } from 'next' +import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Card, @@ -75,6 +74,7 @@ const statusColors: Record - @@ -122,8 +120,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {

You may not have access to view this project.

-
@@ -140,11 +138,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{/* Header */}
-
diff --git a/src/app/(mentor)/mentor/resources/[id]/page.tsx b/src/app/(mentor)/mentor/resources/[id]/page.tsx index e543663..a81a32f 100644 --- a/src/app/(mentor)/mentor/resources/[id]/page.tsx +++ b/src/app/(mentor)/mentor/resources/[id]/page.tsx @@ -1,8 +1,7 @@ 'use client' import { useEffect } from 'react' -import { useParams } from 'next/navigation' -import Link from 'next/link' +import { useParams, useRouter } from 'next/navigation' import dynamic from 'next/dynamic' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -27,6 +26,7 @@ const ResourceRenderer = dynamic( export default function MentorResourceDetailPage() { const params = useParams() + const router = useRouter() const resourceId = params.id as string const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId }) @@ -73,11 +73,9 @@ export default function MentorResourceDetailPage() { This resource may have been removed or you don't have access. -
) @@ -87,11 +85,9 @@ export default function MentorResourceDetailPage() {
{/* Header */}
-
{resource.externalUrl && ( diff --git a/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx b/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx index 38abb69..eb149a8 100644 --- a/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx +++ b/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx @@ -1,8 +1,6 @@ 'use client' -import { useParams } from 'next/navigation' -import Link from 'next/link' -import type { Route } from 'next' +import { useParams, useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -16,6 +14,7 @@ import { toast } from 'sonner' export default function MentorWorkspaceDetailPage() { const params = useParams() + const router = useRouter() const projectId = params.projectId as string // Get mentor assignment for this project @@ -39,11 +38,9 @@ export default function MentorWorkspaceDetailPage() { if (!project) { return (
- @@ -58,11 +55,9 @@ export default function MentorWorkspaceDetailPage() { return (
-
diff --git a/src/app/(mentor)/mentor/workspace/page.tsx b/src/app/(mentor)/mentor/workspace/page.tsx index 6324445..c630203 100644 --- a/src/app/(mentor)/mentor/workspace/page.tsx +++ b/src/app/(mentor)/mentor/workspace/page.tsx @@ -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 @@ const statusColors: Record
-
diff --git a/src/app/(public)/my-submission/[id]/submission-detail-client.tsx b/src/app/(public)/my-submission/[id]/submission-detail-client.tsx index 9ca8802..f1d2a0e 100644 --- a/src/app/(public)/my-submission/[id]/submission-detail-client.tsx +++ b/src/app/(public)/my-submission/[id]/submission-detail-client.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { useParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import Link from 'next/link' import type { Route } from 'next' import { useSession } from 'next-auth/react' @@ -67,6 +67,7 @@ const fileTypeLabels: Record = { export function SubmissionDetailClient() { const params = useParams() + const router = useRouter() const { data: session } = useSession() const projectId = params.id as string const [activeTab, setActiveTab] = useState('details') @@ -116,11 +117,9 @@ export function SubmissionDetailClient() { {error?.message || 'Submission not found'} -
) @@ -133,11 +132,9 @@ export function SubmissionDetailClient() {
{/* Header */}
-
diff --git a/src/app/(public)/my-submission/[id]/team/page.tsx b/src/app/(public)/my-submission/[id]/team/page.tsx index 031d04f..2d18755 100644 --- a/src/app/(public)/my-submission/[id]/team/page.tsx +++ b/src/app/(public)/my-submission/[id]/team/page.tsx @@ -203,10 +203,8 @@ export default function TeamManagementPage() { {/* Header */}
-

diff --git a/src/components/admin/semi-finalists-content.tsx b/src/components/admin/semi-finalists-content.tsx index 0f38130..26a0766 100644 --- a/src/components/admin/semi-finalists-content.tsx +++ b/src/components/admin/semi-finalists-content.tsx @@ -3,6 +3,7 @@ import { useState, useMemo } from 'react' 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 { Input } from '@/components/ui/input' @@ -55,6 +56,7 @@ type SemiFinalistsContentProps = { } export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) { + const router = useRouter() const { data, isLoading } = trpc.dashboard.getSemiFinalistDetail.useQuery( { editionId }, { enabled: !!editionId } @@ -116,11 +118,9 @@ export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) { {/* Header */}
- - - +

Semi-Finalists diff --git a/src/components/layouts/role-nav.tsx b/src/components/layouts/role-nav.tsx index 2033f85..0e1d434 100644 --- a/src/components/layouts/role-nav.tsx +++ b/src/components/layouts/role-nav.tsx @@ -142,8 +142,6 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi logNavClick.mutate({ url: item.href })} > @@ -291,8 +289,6 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi { logNavClick.mutate({ url: item.href }); setIsMobileMenuOpen(false) }} className={className} > diff --git a/src/components/observer/observer-project-detail.tsx b/src/components/observer/observer-project-detail.tsx index b0c7668..f76accf 100644 --- a/src/components/observer/observer-project-detail.tsx +++ b/src/components/observer/observer-project-detail.tsx @@ -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, @@ -43,6 +44,7 @@ import { import { cn, formatDate, formatDateOnly } from '@/lib/utils' export function ObserverProjectDetail({ projectId }: { projectId: string }) { + const router = useRouter() const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery( { id: projectId }, { refetchInterval: 30_000 }, @@ -78,8 +80,8 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {

Project Not Found

-
@@ -152,11 +154,9 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) { return (
{/* Back button */} - {/* Project Header */} diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 5f5bde3..afcac06 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -927,13 +927,13 @@ function PlatformFeaturesSection({ settings }: { settings: RecordLearning Hub diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b7caa72..bd88afc 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -64,7 +64,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // (status NONE) who have no password yet. const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase().trim() }, - select: { status: true }, + select: { id: true, status: true }, }) if (!existingUser || existingUser.status === 'SUSPENDED') { // Silently skip — don't reveal whether the email exists (prevents enumeration) @@ -72,6 +72,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ return } await sendMagicLinkEmail(email, url) + + // Audit: magic link sent + await prisma.auditLog.create({ + data: { + userId: existingUser.id, + action: 'MAGIC_LINK_SENT', + entityType: 'User', + entityId: existingUser.id, + detailsJson: { email, timestamp: new Date().toISOString() }, + }, + }).catch(() => {}) }, }), // Credentials provider for email/password login and invite token auth diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 7b8a974..3b61923 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -1628,14 +1628,44 @@ export const userRouter = router({ }) if (!user) { + await logAudit({ + prisma: ctx.prisma, + userId: null, + action: 'PASSWORD_RESET_LINK_INVALID', + entityType: 'User', + entityId: 'unknown', + detailsJson: { reason: 'token_not_found', timestamp: new Date().toISOString() }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }).catch(() => {}) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid or expired reset link. Please request a new one.' }) } if (user.status === 'SUSPENDED') { + await logAudit({ + prisma: ctx.prisma, + userId: user.id, + action: 'PASSWORD_RESET_LINK_INVALID', + entityType: 'User', + entityId: user.id, + detailsJson: { reason: 'account_suspended', email: user.email, timestamp: new Date().toISOString() }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }).catch(() => {}) throw new TRPCError({ code: 'FORBIDDEN', message: 'This account has been suspended.' }) } if (user.passwordResetExpiresAt && user.passwordResetExpiresAt < new Date()) { + await logAudit({ + prisma: ctx.prisma, + userId: user.id, + action: 'PASSWORD_RESET_LINK_EXPIRED', + entityType: 'User', + entityId: user.id, + detailsJson: { email: user.email, expiredAt: user.passwordResetExpiresAt.toISOString(), timestamp: new Date().toISOString() }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }).catch(() => {}) // Clear expired token await ctx.prisma.user.update({ where: { id: user.id }, @@ -1644,6 +1674,18 @@ export const userRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: 'This reset link has expired. Please request a new one.' }) } + // Audit: reset link clicked and valid + await logAudit({ + prisma: ctx.prisma, + userId: user.id, + action: 'PASSWORD_RESET_LINK_CLICKED', + entityType: 'User', + entityId: user.id, + detailsJson: { email: user.email, timestamp: new Date().toISOString() }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }).catch(() => {}) + // Hash and save new password, clear reset token const passwordHash = await hashPassword(input.password) await ctx.prisma.user.update({