From a39e27f6ff775764f04e7f56574d348453789453 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Mar 2026 13:29:39 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20applicant=20portal=20=E2=80=94=20documen?= =?UTF-8?q?t=20uploads,=20round=20filtering,=20auth=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round-specific document uploads (submittedAt no longer blocks uploads), add view/download buttons for existing files, enforce active-round-only for uploads/deletes. Harden auth layout and set-password page. Filter applicant portal rounds by award track membership. Co-Authored-By: Claude Opus 4.6 --- .../(applicant)/applicant/documents/page.tsx | 63 +++++++++++-- src/app/(applicant)/applicant/page.tsx | 20 ++++- src/app/(applicant)/applicant/team/page.tsx | 5 +- src/app/(auth)/layout.tsx | 5 +- src/app/(auth)/set-password/page.tsx | 12 +-- src/lib/auth.ts | 25 +++++- src/lib/storage/index.ts | 10 ++- src/server/routers/applicant.ts | 89 +++++++++++++++++-- 8 files changed, 192 insertions(+), 37 deletions(-) diff --git a/src/app/(applicant)/applicant/documents/page.tsx b/src/app/(applicant)/applicant/documents/page.tsx index 8b53d45..eed3343 100644 --- a/src/app/(applicant)/applicant/documents/page.tsx +++ b/src/app/(applicant)/applicant/documents/page.tsx @@ -3,6 +3,7 @@ import { useSession } from 'next-auth/react' import { trpc } from '@/lib/trpc/client' import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { Card, CardContent, @@ -20,6 +21,7 @@ import { Video, File, Download, + Eye, } from 'lucide-react' const fileTypeIcons: Record = { @@ -42,6 +44,34 @@ const fileTypeLabels: Record = { SUPPORTING_DOC: 'Supporting Document', } +function FileActionButtons({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) { + const { data: viewData } = trpc.file.getDownloadUrl.useQuery( + { bucket, objectKey, forDownload: false }, + { staleTime: 10 * 60 * 1000 } + ) + const { data: dlData } = trpc.file.getDownloadUrl.useQuery( + { bucket, objectKey, forDownload: true, fileName }, + { staleTime: 10 * 60 * 1000 } + ) + const viewUrl = typeof viewData === 'string' ? viewData : viewData?.url + const dlUrl = typeof dlData === 'string' ? dlData : dlData?.url + + return ( +
+ + +
+ ) +} + export default function ApplicantDocumentsPage() { const { status: sessionStatus } = useSession() const isAuthenticated = sessionStatus === 'authenticated' @@ -82,7 +112,7 @@ export default function ApplicantDocumentsPage() { ) } - const { project, openRounds } = data + const { project, openRounds, isRejected } = data const isDraft = !project.submittedAt return ( @@ -98,8 +128,20 @@ export default function ApplicantDocumentsPage() {

+ {/* Rejected banner */} + {isRejected && ( + + + +

+ Your project was not selected to advance. Documents are view-only. +

+
+
+ )} + {/* Per-round upload sections */} - {openRounds.length > 0 && ( + {!isRejected && openRounds.length > 0 && (
{openRounds.map((round: { id: string; name: string; windowCloseAt?: string | Date | null }) => { const now = new Date() @@ -163,18 +205,18 @@ export default function ApplicantDocumentsPage() {
{project.files.map((file) => { const Icon = fileTypeIcons[file.fileType] || File - const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null } + const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null; bucket?: string; objectKey?: string } return (
-
- -
+
+ +
-

{file.fileName}

+

{file.fileName}

{fileRecord.isLate && ( @@ -189,6 +231,13 @@ export default function ApplicantDocumentsPage() {

+ {fileRecord.bucket && fileRecord.objectKey && ( + + )}
) })} diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx index b6b0509..847193a 100644 --- a/src/app/(applicant)/applicant/page.tsx +++ b/src/app/(applicant)/applicant/page.tsx @@ -111,7 +111,7 @@ export default function ApplicantDashboardPage() { ) } - const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data + const { project, timeline, currentStatus, openRounds, hasPassedIntake, isRejected } = data const programYear = project.program?.year const programName = project.program?.name const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0 @@ -221,8 +221,23 @@ export default function ApplicantDashboardPage() { + {/* Rejected banner */} + {isRejected && ( + + + + +

+ Your project was not selected to advance. Your project space is now read-only. +

+
+
+
+ )} + {/* Quick actions */} - + {!isRejected && ( +
@@ -266,6 +281,7 @@ export default function ApplicantDashboardPage() { )}
+ )} {/* Document Completeness */} {docCompleteness && docCompleteness.length > 0 && ( diff --git a/src/app/(applicant)/applicant/team/page.tsx b/src/app/(applicant)/applicant/team/page.tsx index 953e163..dd0255f 100644 --- a/src/app/(applicant)/applicant/team/page.tsx +++ b/src/app/(applicant)/applicant/team/page.tsx @@ -123,6 +123,7 @@ export default function ApplicantProjectPage() { const project = dashboardData?.project const projectId = project?.id const isIntakeOpen = dashboardData?.isIntakeOpen ?? false + const isRejected = dashboardData?.isRejected ?? false const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery( { projectId: projectId! }, @@ -398,7 +399,7 @@ export default function ApplicantProjectPage() { Everyone on this list can view and collaborate on this project.
- {isTeamLead && ( + {isTeamLead && !isRejected && (
- {isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && ( + {isTeamLead && !isRejected && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (