From 5d7969d688e3093fbed95af875884aa0db75cab9 Mon Sep 17 00:00:00 2001 From: engdotme Date: Mon, 8 Jun 2026 16:29:04 -0400 Subject: [PATCH 01/31] chore: use Nest Logger for bootstrap logging --- backend/src/main.ts | 8 ++- docs/admin-completion-todo.md | 115 ---------------------------------- docs/student-features.md | 109 -------------------------------- 3 files changed, 5 insertions(+), 227 deletions(-) delete mode 100644 docs/admin-completion-todo.md delete mode 100644 docs/student-features.md diff --git a/backend/src/main.ts b/backend/src/main.ts index 5dd3e5f..3bd2fbc 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,6 +1,6 @@ import 'tsconfig-paths/register'; -import { ValidationPipe } from '@nestjs/common'; +import { Logger, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import type { NestExpressApplication } from '@nestjs/platform-express'; @@ -54,9 +54,11 @@ async function bootstrap(): Promise { setupSwagger(app); await app.listen(port); - console.log(`πŸš€ forgeng API running on http://localhost:${port}/api`); + + const logger = new Logger('Bootstrap'); + logger.log(`πŸš€ forgeng API running on http://localhost:${port}/api`); if (config.getOrThrow('nodeEnv', { infer: true }) !== 'production') { - console.log(`πŸ“– OpenAPI docs at http://localhost:${port}/api/docs`); + logger.log(`πŸ“– OpenAPI docs at http://localhost:${port}/api/docs`); } } void bootstrap(); diff --git a/docs/admin-completion-todo.md b/docs/admin-completion-todo.md deleted file mode 100644 index 440f4e0..0000000 --- a/docs/admin-completion-todo.md +++ /dev/null @@ -1,115 +0,0 @@ -# Admin Page Completion β€” TODO - -Goal: make every admin action actually work against the backend and have it -flow through to the student portal. Grounded in the current code -(`frontend/src/app/admin/*`, `frontend/src/features/*`) and the live backend -endpoints (`backend/src/modules/*`). - -## Current state (audit) - -| Admin area | UI exists | API wired | Notes | -| --- | --- | --- | --- | -| Dashboard (`/admin`) | βœ… | βœ… | Read-only stats from `/dashboard/admin` | -| Applications (`/admin/applications`) | βœ… | βœ… | Status updates work (`PATCH /applications/:id/status`) | -| Review Queue (`/admin/reviews`) | βœ… | βœ… | Feedback works (`POST /submissions/:id/feedback`) | -| Users (`/admin/users`) | βœ… | βœ… | Role change works (`PATCH /users/:id/role`) | -| **Cohorts** (`/admin/cohorts`) | βœ… | ❌ | create / edit / delete / enroll are **stubs** (`setTimeout` + toast) | -| **Tasks** (`/admin/tasks`) | βœ… | ❌ | create / edit / delete are **stubs** (`setTimeout` + toast) | - -Backend endpoints for the stubbed areas **already exist** β€” only the frontend -`api.ts` / `hooks.ts` and the dialog handlers are missing. - ---- - -## Phase 1 β€” Wire Cohort CRUD + enrollment - -Backend (already live): `POST /cohorts`, `PATCH /cohorts/:id`, -`DELETE /cohorts/:id`, `POST /cohorts/:id/enroll`. - -- [ ] **Add API functions** in `frontend/src/features/cohorts/api.ts`: - - `createCohort(input)` β†’ `POST /cohorts` - - `updateCohort(id, input)` β†’ `PATCH /cohorts/:id` - - `deleteCohort(id)` β†’ `DELETE /cohorts/:id` - - `enrollStudent(cohortId, userId)` β†’ `POST /cohorts/:id/enroll` - - Define a `CohortInput` type matching `CreateCohortDto` (name, description?, - capacity, status?, startDate?, endDate?). -- [ ] **Export** the new functions from `frontend/src/features/cohorts/index.ts`. -- [ ] **Replace the stub** in `components/form-dialog.tsx` `handleSave`: call - `createCohort` / `updateCohort` instead of the `setTimeout`, surface - `ApiError.message` on failure (mirror `users/components/row.tsx`). -- [ ] **Replace the stub** in `components/card.tsx` `handleDelete`: call - `deleteCohort(cohort.id)`. -- [ ] **Replace the stub** in `components/enrollments.tsx` `handleEnroll`: call - `enrollStudent(cohort.id, Number(userId))`. -- [ ] **Refresh after mutate**: thread the `refetch` from `useCohorts()` - (page-level) into `FormDialog` / `Card` / `Enrollments` so the list and - `enrolledCount` update without a page reload. `useEnrollments` also needs to - refetch after enroll. - -## Phase 2 β€” Wire Task CRUD - -Backend (already live): `POST /tasks`, `PATCH /tasks/:id`, `DELETE /tasks/:id`. - -- [ ] **Add API functions** in `frontend/src/features/tasks/api.ts`: - - `createTask(input)` β†’ `POST /tasks` - - `updateTask(id, input)` β†’ `PATCH /tasks/:id` - - `deleteTask(id)` β†’ `DELETE /tasks/:id` - - Define `TaskInput` matching `CreateTaskDto` (cohortId, title, description?, - type, status?, dueDate?). -- [ ] **Export** them from `frontend/src/features/tasks/index.ts`. -- [ ] **Replace the stub** in `components/form-dialog.tsx` `handleSave`: call - `createTask` / `updateTask`. -- [ ] **Replace the stub** in `components/row.tsx` `handleDelete`: call - `deleteTask(task.id)`. -- [ ] **Refresh after mutate**: thread `refetch` from `useTasks()` into - `FormDialog` and `Row` (the `/admin/tasks` page currently never refetches). - -## Phase 3 β€” Shared polish for the wired flows - -- [ ] **Consistent error handling**: every mutation should catch `ApiError` and - `toast.error(err.message)`; only `toast.success` on resolve (today's stubs - always "succeed"). Use `users/components/row.tsx` as the reference pattern. -- [ ] **Disable + spinner while pending** on every submit/delete button - (`isSaving` already exists in the dialogs β€” keep it, just gate on the real - promise). -- [ ] **Confirm-before-delete**: cohort/task delete already use `confirm()`; - decide whether to keep native confirm or swap for an AlertDialog for - consistency with the rest of the UI. - -## Phase 4 β€” Admin ↔ Student portal integration (verify the loop) - -These features already exist on the student side; this phase is about -confirming admin actions actually drive them end-to-end. - -- [ ] **Publish β†’ visible**: a task created/edited with `status: published` - appears on `/student/tasks` for enrolled students; `draft` does not. -- [ ] **Enroll β†’ cohort page**: enrolling a student shows the cohort on - `/student/cohort` and unblocks the "not enrolled" empty state. -- [ ] **Feedback β†’ notifications**: leaving feedback (`POST .../feedback`) - produces the student notification (`feedback-received` template exists in - `backend/src/modules/notifications/templates`) and surfaces in the bell + - `/student/notifications`. -- [ ] **New task β†’ notification**: confirm publishing a task fires the - `task-published` notification to enrolled students (template exists). -- [ ] **Application accept β†’ enrollment**: accepting an application with a - `cohortId` (`updateApplicationStatus`) enrolls/promotes the applicant; verify - the resulting student can log in and see their cohort. -- [ ] **Counts stay in sync**: after the above, the admin dashboard cards - (pending reviews, enrolled students, pending applications) reflect reality on - refetch. - -## Phase 5 β€” Verification - -- [ ] `cd frontend; npm run lint` and `npm run build` clean. -- [ ] `cd backend; npm run lint` clean (no backend changes expected, but verify). -- [ ] Manual run-through of each admin mutation against a running backend - (create/edit/delete cohort, enroll student, create/edit/delete task) and the - Phase 4 student-side checks. - ---- - -## Out of scope (future, larger items) - -Tracked in `docs/student-features.md` β€” not required to "complete" the admin -page: quiz authoring/grading UI, admin-side analytics charts, admin notification -preferences, per-cohort task/submission drill-down views. diff --git a/docs/student-features.md b/docs/student-features.md deleted file mode 100644 index 90fa160..0000000 --- a/docs/student-features.md +++ /dev/null @@ -1,109 +0,0 @@ -# Student Portal β€” Feature Proposals - -Ideas for fleshing out the student experience. Grounded in the current code -(`frontend/src/app/student/*`, `frontend/src/features/{dashboard,tasks,submissions}`) -and the existing data model (`backend/prisma/schema.prisma`). - -## Where things stand today - -The student portal currently has three thin pages: - -| Page | Route | What it does | -| --- | --- | --- | -| Dashboard | `/student` | Read-only stat cards: progress %, pending count, next deadline, 5 recent submissions | -| Tasks | `/student/tasks` | Flat list of published tasks; a Submit button per task | -| Submissions | `/student/submissions` | List of own submissions with a detail sheet | - -**The gap:** everything is a snapshot. A student can submit work and watch a -status badge change, but there's no way to read feedback, resubmit, take a quiz, -see the cohort, or manage their own profile β€” even though the schema already -models most of this. - ---- - -## Tier 1 β€” Close the loops that already exist - -These use data the backend already stores; mostly frontend + thin endpoints. - -### 1. Feedback & resubmission flow -The `Feedback` model (content + `approved`/`needs_work` verdict) is captured by -admins but **never shown to students**. When a submission is `needs_work`, the -student hits a dead end. -- Show reviewer feedback inside the submission detail sheet. -- When status is `needs_work`, allow editing/resubmitting against the same task - (the Tasks page currently only offers Submit when no submission exists). -- Surface a "X submissions need your attention" callout on the dashboard. - -### 2. Task detail page (`/student/tasks/[id]`) -Task cards truncate the title and description (`truncate` in `tasks/page.tsx`). -A reading or project task with real instructions has nowhere to live. -- Full description, due date, type, and this task's submission history + feedback. -- Submit/resubmit from the detail view. - -### 3. Quiz tasks -`TaskType.quiz` exists in the schema but there is no quiz UI β€” quizzes currently -behave like every other "submit a link/text" task. -- Decide on a question model (likely a new `Question`/`QuizAttempt` table) and a - take-quiz flow with auto-grading for the `quiz` type. - -### 4. Filtering, sorting & search on Tasks -The list is unsorted and unfiltered. As cohorts accumulate tasks this gets noisy. -- Filter by type (coding/reading/project/quiz) and status (todo / submitted / - approved / needs work). -- Sort by due date; highlight overdue tasks (no overdue treatment exists today). - ---- - -## Tier 2 β€” Give the student a richer picture - -### 5. Profile page (`/student/profile`) -`User.bio`, `githubUrl`, and `avatarUrl` exist but students can't edit them, and -the sidebar only shows name/email. -- Edit profile (name, bio, GitHub, avatar). -- Show enrollment history. - -### 6. Cohort overview (`/student/cohort`) -`Cohort` has `description`, `startDate`, `endDate`, `capacity`, and `status`, -none of which the student sees beyond the cohort name. -- Cohort details, timeline/schedule, and optionally cohort-mates. - -### 7. Richer progress & analytics on the dashboard -Progress is a single `approved / total` percentage today. -- Break down by task type, show a submitted-vs-approved split, completion trend - over time, and a streak / activity indicator. - -### 8. Empty-state onboarding for unenrolled students -A student with no enrollment sees "You are not enrolled in a cohort yet." and a -dead end. Add guidance / next steps (or a path back to application status). - ---- - -## Tier 3 β€” Engagement & polish - -### 9. Notifications -No notification system exists. Students aren't told when feedback arrives, a new -task is published, or a deadline is near. -- In-app notification center (new tables) and/or email via the existing mail - setup used for auth verification. - -### 10. Deadline reminders / calendar -Surface upcoming due dates as a list or calendar; "due soon" badges on tasks. - -### 11. Submission drafts & history -Allow saving a draft before submitting, and keep full version history per task -rather than a single mutable submission. - ---- - -## Suggested sequencing - -1. **Tier 1** delivers the most value per unit of work β€” it makes the - submit β†’ review β†’ feedback β†’ resubmit loop actually complete, which is the - core purpose of an apprenticeship platform. -2. Quiz tasks (#3) are the largest single item (new schema + grading) β€” consider - carving them into their own follow-up branch. -3. Tier 2 and Tier 3 are additive and can ship incrementally. - -> Scope note: "complete student functionality" most naturally means Tier 1 + -> the profile/cohort pages from Tier 2. Quizzes and notifications are large -> enough to deserve their own branches. From 173fc721a424e4f5fc3acab4a979e377af97871b Mon Sep 17 00:00:00 2001 From: engdotme Date: Mon, 8 Jun 2026 16:30:20 -0400 Subject: [PATCH 02/31] chore: enforce no-floating-promises as an error --- backend/eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index 4e9f827..c339daa 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -27,7 +27,7 @@ export default tseslint.config( { rules: { '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-unsafe-argument': 'warn', "prettier/prettier": ["error", { endOfLine: "auto" }], }, From 518f878145ecf4c04d519340ddb57a2f9d0d480f Mon Sep 17 00:00:00 2001 From: engdotme Date: Mon, 8 Jun 2026 16:32:37 -0400 Subject: [PATCH 03/31] chore: enable full TypeScript strict mode --- backend/tsconfig.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 00c5d4b..dcabf9d 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -23,10 +23,8 @@ }, "incremental": true, "skipLibCheck": true, - "strictNullChecks": true, + "strict": true, "forceConsistentCasingInFileNames": true, - "noImplicitAny": true, - "strictBindCallApply": true, "noFallthroughCasesInSwitch": true } } From dff57225b5ac972f978ed484c24df1b30e72969b Mon Sep 17 00:00:00 2001 From: engdotme Date: Mon, 8 Jun 2026 16:44:38 -0400 Subject: [PATCH 04/31] feat: add rate limiting to API and auth routes --- backend/package.json | 1 + backend/src/app.module.ts | 6 ++++++ backend/src/modules/auth/auth.constants.ts | 7 +++++++ backend/src/modules/auth/auth.controller.ts | 7 +++++++ backend/src/modules/health/health.controller.ts | 2 ++ pnpm-lock.yaml | 16 ++++++++++++++++ 6 files changed, 39 insertions(+) diff --git a/backend/package.json b/backend/package.json index b415396..ec51970 100644 --- a/backend/package.json +++ b/backend/package.json @@ -38,6 +38,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.4.4", + "@nestjs/throttler": "^6.5.0", "@prisma/client": "^6.3.1", "bcryptjs": "^3.0.2", "class-transformer": "^0.5.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1cb46f2..660f97b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,4 +1,6 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { AppConfigModule } from '@config'; import { CoreModule } from '@core/core.module'; @@ -17,6 +19,9 @@ import { UsersModule } from '@modules/users/users.module'; @Module({ imports: [ AppConfigModule, + // Baseline rate limit applied to every route (100 requests/min per IP). + // Sensitive auth routes tighten this further via @Throttle. + ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]), CoreModule, AuthModule, HealthModule, @@ -30,5 +35,6 @@ import { UsersModule } from '@modules/users/users.module'; DashboardModule, NotificationsModule, ], + providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }], }) export class AppModule {} diff --git a/backend/src/modules/auth/auth.constants.ts b/backend/src/modules/auth/auth.constants.ts index 2bade3f..569e115 100644 --- a/backend/src/modules/auth/auth.constants.ts +++ b/backend/src/modules/auth/auth.constants.ts @@ -8,3 +8,10 @@ export const PASSWORD_MAX_LENGTH = 72; /** At least one letter and one digit. */ export const PASSWORD_PATTERN = /^(?=.*[A-Za-z])(?=.*\d).+$/; export const PASSWORD_MESSAGE = 'Password must contain a letter and a digit.'; + +/** + * Tighter rate limit for sensitive unauthenticated auth endpoints β€” login, + * register, password reset, and verification β€” to curb brute-force and email + * abuse: 5 requests per minute per IP (vs. the global 100/min default). + */ +export const AUTH_THROTTLE = { default: { limit: 5, ttl: 60_000 } }; diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index e01287f..6727ab7 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -13,9 +13,11 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; import type { AppConfiguration } from '@config'; +import { AUTH_THROTTLE } from './auth.constants'; import { Public } from '@core/auth/public.decorator'; import type { AuthUser } from '@core/auth/auth.types'; import { CurrentUser } from '@core/auth/current-user.decorator'; @@ -49,6 +51,7 @@ export class AuthController { ) {} @Public() + @Throttle(AUTH_THROTTLE) @Post('register') @HttpCode(HttpStatus.CREATED) async register( @@ -59,6 +62,7 @@ export class AuthController { } @Public() + @Throttle(AUTH_THROTTLE) @Post('login') @HttpCode(HttpStatus.OK) async login( @@ -105,6 +109,7 @@ export class AuthController { } @Public() + @Throttle(AUTH_THROTTLE) @Post('resend-verification') @HttpCode(HttpStatus.ACCEPTED) async resendVerification(@Body() dto: ResendVerificationDto): Promise { @@ -112,6 +117,7 @@ export class AuthController { } @Public() + @Throttle(AUTH_THROTTLE) @Post('forgot-password') @HttpCode(HttpStatus.ACCEPTED) async forgotPassword(@Body() dto: ForgotPasswordDto): Promise { @@ -119,6 +125,7 @@ export class AuthController { } @Public() + @Throttle(AUTH_THROTTLE) @Post('reset-password') @HttpCode(HttpStatus.NO_CONTENT) async resetPassword(@Body() dto: ResetPasswordDto): Promise { diff --git a/backend/src/modules/health/health.controller.ts b/backend/src/modules/health/health.controller.ts index a4670c2..d0ee616 100644 --- a/backend/src/modules/health/health.controller.ts +++ b/backend/src/modules/health/health.controller.ts @@ -1,10 +1,12 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; import { Public } from '@core/auth/public.decorator'; import { PrismaService } from '@core/database/prisma.service'; @ApiTags('health') @Controller('healthz') +@SkipThrottle() export class HealthController { constructor(private readonly prisma: PrismaService) {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 694b3ee..16cb5f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@nestjs/swagger': specifier: ^11.4.4 version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@prisma/client': specifier: ^6.3.1 version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) @@ -1124,6 +1127,13 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + '@next/env@16.2.6': resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} @@ -6233,6 +6243,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + '@next/env@16.2.6': {} '@next/eslint-plugin-next@16.2.6': From dc95758e1eeb343de971c0312fa9f4fcd220906d Mon Sep 17 00:00:00 2001 From: engdotme Date: Mon, 8 Jun 2026 17:03:38 -0400 Subject: [PATCH 05/31] chore: migrate Prisma seed config to prisma.config.ts --- backend/package.json | 4 +--- backend/prisma.config.ts | 15 +++++++++++++++ pnpm-lock.yaml | 9 +++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 backend/prisma.config.ts diff --git a/backend/package.json b/backend/package.json index ec51970..ebc7d18 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,9 +27,6 @@ "prisma:studio": "prisma studio", "db:seed": "ts-node prisma/seed.ts" }, - "prisma": { - "seed": "ts-node prisma/seed.ts" - }, "dependencies": { "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", @@ -74,6 +71,7 @@ "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", "@types/supertest": "^7.0.0", + "dotenv": "^17.4.2", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts new file mode 100644 index 0000000..7c6540e --- /dev/null +++ b/backend/prisma.config.ts @@ -0,0 +1,15 @@ +import path from 'node:path'; + +// A Prisma config file disables Prisma's automatic .env loading, so load it +// ourselves to keep `DATABASE_URL` (and friends) available to the CLI. +import 'dotenv/config'; +import { defineConfig } from 'prisma/config'; + +// Replaces the deprecated `package.json#prisma` block (removed in Prisma 7). +export default defineConfig({ + schema: path.join('prisma', 'schema.prisma'), + migrations: { + // Run after `prisma migrate dev` / `migrate reset` apply migrations. + seed: 'ts-node prisma/seed.ts', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16cb5f3..14ffcdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: '@types/supertest': specifier: ^7.0.0 version: 7.2.0 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 eslint: specifier: ^9.18.0 version: 9.39.4(jiti@2.7.0) @@ -2854,6 +2857,10 @@ packages: resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -7932,6 +7939,8 @@ snapshots: dotenv@17.4.1: {} + dotenv@17.4.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 From 31d6aee48b18bcae011df9875f6b0cc8b9e7d23b Mon Sep 17 00:00:00 2001 From: engdotme Date: Mon, 8 Jun 2026 17:09:59 -0400 Subject: [PATCH 06/31] test: fix e2e jest config and add first unit specs --- backend/src/common/mappers/mappers.spec.ts | 57 +++++++++++++++++++ .../auth/services/password.service.spec.ts | 30 ++++++++++ backend/test/jest-e2e.json | 7 +++ 3 files changed, 94 insertions(+) create mode 100644 backend/src/common/mappers/mappers.spec.ts create mode 100644 backend/src/modules/auth/services/password.service.spec.ts diff --git a/backend/src/common/mappers/mappers.spec.ts b/backend/src/common/mappers/mappers.spec.ts new file mode 100644 index 0000000..84f8ce4 --- /dev/null +++ b/backend/src/common/mappers/mappers.spec.ts @@ -0,0 +1,57 @@ +import type { User } from '@prisma/client'; + +import { toUserDto } from './index'; + +const makeUser = (overrides: Partial = {}): User => + ({ + id: 1, + clerkId: null, + email: 'ada@example.com', + emailVerified: true, + name: 'Ada', + role: 'student', + bio: null, + githubUrl: null, + avatarUrl: null, + registrationIp: null, + registrationCountry: null, + registrationCity: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + ...overrides, + }) as unknown as User; + +describe('toUserDto', () => { + it('serializes createdAt to an ISO string', () => { + const dto = toUserDto(makeUser()); + expect(dto.createdAt).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('copies scalar fields straight through', () => { + const dto = toUserDto( + makeUser({ email: 'grace@example.com', name: 'Grace' }), + ); + expect(dto.email).toBe('grace@example.com'); + expect(dto.name).toBe('Grace'); + }); + + it('defaults social links to null when no application socials are given', () => { + const dto = toUserDto(makeUser()); + expect(dto.linkedin).toBeNull(); + expect(dto.github).toBeNull(); + expect(dto.whatsapp).toBeNull(); + }); + + it('pulls social links from the provided application socials', () => { + const dto = toUserDto(makeUser(), { + linkedin: 'https://linkedin.com/in/ada', + twitter: null, + facebook: null, + github: 'https://github.com/ada', + portfolio: null, + telegram: null, + whatsapp: null, + }); + expect(dto.linkedin).toBe('https://linkedin.com/in/ada'); + expect(dto.github).toBe('https://github.com/ada'); + }); +}); diff --git a/backend/src/modules/auth/services/password.service.spec.ts b/backend/src/modules/auth/services/password.service.spec.ts new file mode 100644 index 0000000..9d9d241 --- /dev/null +++ b/backend/src/modules/auth/services/password.service.spec.ts @@ -0,0 +1,30 @@ +import { PasswordService } from './password.service'; + +describe('PasswordService', () => { + const service = new PasswordService(); + + it('hashes a password to a non-plaintext bcrypt string', async () => { + const hash = await service.hash('s3cret-pw'); + expect(hash).not.toBe('s3cret-pw'); + // bcrypt hashes start with the $2a/$2b/$2y identifier. + expect(hash).toMatch(/^\$2[aby]\$/); + }); + + it('verifies a correct password', async () => { + const hash = await service.hash('s3cret-pw'); + await expect(service.verify('s3cret-pw', hash)).resolves.toBe(true); + }); + + it('rejects an incorrect password', async () => { + const hash = await service.hash('s3cret-pw'); + await expect(service.verify('wrong-pw', hash)).resolves.toBe(false); + }); + + it('produces a different hash each call (random salt)', async () => { + const [a, b] = await Promise.all([ + service.hash('same-input'), + service.hash('same-input'), + ]); + expect(a).not.toBe(b); + }); +}); diff --git a/backend/test/jest-e2e.json b/backend/test/jest-e2e.json index e9d912f..5216b06 100644 --- a/backend/test/jest-e2e.json +++ b/backend/test/jest-e2e.json @@ -5,5 +5,12 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^@core/(.*)$": "/../src/core/$1", + "^@common/(.*)$": "/../src/common/$1", + "^@config$": "/../src/config", + "^@config/(.*)$": "/../src/config/$1", + "^@modules/(.*)$": "/../src/modules/$1" } } From 5c38b0608897e622488e29978d66fbf1ab03f839 Mon Sep 17 00:00:00 2001 From: engdotme Date: Tue, 9 Jun 2026 13:40:23 -0400 Subject: [PATCH 07/31] refactor: create reusable ListPageLayout component for admin list pages --- frontend/src/app/admin/applications/page.tsx | 113 ++---------- frontend/src/components/shared/index.ts | 5 + .../components/shared/list-page-layout.tsx | 162 ++++++++++++++++++ 3 files changed, 182 insertions(+), 98 deletions(-) create mode 100644 frontend/src/components/shared/list-page-layout.tsx diff --git a/frontend/src/app/admin/applications/page.tsx b/frontend/src/app/admin/applications/page.tsx index 8a0550f..3d6b2df 100644 --- a/frontend/src/app/admin/applications/page.tsx +++ b/frontend/src/app/admin/applications/page.tsx @@ -2,117 +2,34 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { ChevronLeft, ChevronRight } from "lucide-react"; -import { LoadingState } from "@components/common"; -import { EmptyState, PageContainer, PageHeader } from "@components/shared"; -import { Button } from "@components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@components/ui/select"; +import { ListPageLayout } from "@components/shared"; import { List, StatusTabs, + type Application, type ApplicationStatusFilter, useApplications, } from "@features/applications"; -import { PAGE_SIZE_OPTIONS } from "@constants/shared/pagination"; const Page = () => { const router = useRouter(); const [filter, setFilter] = useState("all"); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); - - const { data, isLoading } = useApplications(filter, page, pageSize); - - const applications = data?.items ?? []; - const total = data?.total ?? 0; - const totalPages = Math.max(1, Math.ceil(total / pageSize)); - - const handleFilterChange = (value: ApplicationStatusFilter) => { - setFilter(value); - setPage(1); - }; - - const handlePageSizeChange = (value: string) => { - setPageSize(Number(value)); - setPage(1); - }; return ( - - - - - -

- {isLoading - ? "Loading…" - : `${total} ${total === 1 ? "application" : "applications"}`} -

- - {isLoading ? ( - - ) : applications.length === 0 ? ( - - ) : ( - router.push(`/admin/applications/${app.id}`)} - /> - )} - - {total > 0 && ( -
-
- Per page - -
-
- - - Page {page} of {totalPages} - - -
-
- )} -
+ + header={{ + title: "Applications", + description: "Review and manage applicants through the pipeline.", + }} + useData={useApplications} + filter={filter} + filterComponent={} + listComponent={(props) => } + onSelectItem={(app) => router.push(`/admin/applications/${app.id}`)} + emptyMessage="No applications in this category." + loadingMessage="Loading applications…" + /> ); }; diff --git a/frontend/src/components/shared/index.ts b/frontend/src/components/shared/index.ts index d4f6734..6edf0fe 100644 --- a/frontend/src/components/shared/index.ts +++ b/frontend/src/components/shared/index.ts @@ -1,3 +1,8 @@ export { PageContainer, type PageMaxWidth } from "./page-container"; export { PageHeader } from "./page-header"; export { EmptyState } from "./empty-state"; +export { + ListPageLayout, + type ListPageLayoutProps, + type PaginatedData, +} from "./list-page-layout"; diff --git a/frontend/src/components/shared/list-page-layout.tsx b/frontend/src/components/shared/list-page-layout.tsx new file mode 100644 index 0000000..9cbaac5 --- /dev/null +++ b/frontend/src/components/shared/list-page-layout.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { LoadingState } from "@components/common"; +import { Button } from "@components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/ui/select"; +import { PAGE_SIZE_OPTIONS } from "@constants/shared/pagination"; +import { EmptyState, PageContainer, PageHeader } from "./index"; + +export interface PaginatedData { + items: T[]; + total: number; + page: number; + pageSize: number; +} + +export interface ListPageLayoutProps< + T, + TFilter extends string = string, + TData extends PaginatedData = PaginatedData, +> { + /** Title and description for the page header */ + header: { + title: string; + description: string; + }; + + /** Data fetching hook that takes (filter, page, pageSize) */ + useData: ( + filter: TFilter, + page: number, + pageSize: number, + ) => { data?: TData; isLoading: boolean }; + + /** Filter tabs/selector component */ + filterComponent?: React.ReactNode; + + /** Current filter value */ + filter: TFilter; + + + /** List component to render items */ + listComponent: React.ComponentType<{ + items: T[]; + onSelect?: (item: T) => void; + }>; + + /** Optional handler when an item is selected */ + onSelectItem?: (item: T) => void; + + /** Empty state message */ + emptyMessage?: string; + + /** Loading message */ + loadingMessage?: string; + + /** Max container width (default: "5xl") */ + maxWidth?: string; +} + +export const ListPageLayout = < + T, + TFilter extends string = string, + TData extends PaginatedData = PaginatedData, +>({ + header, + useData, + filterComponent, + filter, + listComponent: ListComponent, + onSelectItem, + emptyMessage = "No items found.", + loadingMessage = "Loading…", + maxWidth = "5xl", +}: ListPageLayoutProps) => { + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + + const { data, isLoading } = useData(filter, page, pageSize); + + const items = data?.items ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + const handlePageSizeChange = (value: string) => { + setPageSize(Number(value)); + setPage(1); + }; + + return ( + + + + {filterComponent} + +

+ {isLoading + ? "Loading…" + : `${total} ${total === 1 ? "item" : "items"}`} +

+ + {isLoading ? ( + + ) : items.length === 0 ? ( + + ) : ( + + )} + + {total > 0 && ( +
+
+ Per page + +
+
+ + + Page {page} of {totalPages} + + +
+
+ )} +
+ ); +}; From 9280446a5e52a09afa9db9e7757a7f5ae4ea4ce5 Mon Sep 17 00:00:00 2001 From: engdotme Date: Wed, 10 Jun 2026 12:51:04 -0400 Subject: [PATCH 08/31] refactor: rewrite api-client on axios with interceptors --- frontend/package.json | 1 + frontend/src/lib/api-client.ts | 131 +++++++++++++++------------------ pnpm-lock.yaml | 70 ++++++++++++++++-- 3 files changed, 121 insertions(+), 81 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 106947a..3ca3fe2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "axios": "^1.17.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.3.0", diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index cba309c..833efee 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -1,3 +1,4 @@ +import axios, { type AxiosError } from "axios"; import { API_BASE } from "@lib/config"; import { clearAuth, @@ -20,6 +21,18 @@ interface RefreshSuccess { accessToken: string; } +declare module "axios" { + export interface AxiosRequestConfig { + /** Skip the auto-refresh dance β€” used by the refresh / login calls themselves. */ + skipAuthRetry?: boolean; + } +} + +const http = axios.create({ + baseURL: API_BASE, + withCredentials: true, +}); + /** * One in-flight refresh promise shared by every request that hits a 401. * Without this guard a burst of parallel calls would each try to refresh, @@ -29,15 +42,14 @@ let refreshPromise: Promise | null = null; const performRefresh = async (): Promise => { try { - const response = await fetch(`${API_BASE}/auth/refresh`, { - method: "POST", - credentials: "include", - }); - if (!response.ok) return null; - const json = (await response.json()) as Partial; - if (typeof json.accessToken !== "string") return null; - writeAccessToken(json.accessToken); - return json.accessToken; + const { data } = await axios.post>( + `${API_BASE}/auth/refresh`, + undefined, + { withCredentials: true }, + ); + if (typeof data.accessToken !== "string") return null; + writeAccessToken(data.accessToken); + return data.accessToken; } catch { return null; } @@ -52,46 +64,6 @@ const refreshAccessToken = async (): Promise => { return refreshPromise; }; -interface RequestOptions { - /** Skip the auto-refresh dance β€” used by the refresh / login calls themselves. */ - skipAuthRetry?: boolean; -} - -const buildHeaders = (init: RequestInit | undefined): Headers => { - const headers = new Headers(init?.headers); - // Let the browser set the multipart boundary for FormData bodies. - if ( - !headers.has("Content-Type") && - init?.body && - !(init.body instanceof FormData) - ) { - headers.set("Content-Type", "application/json"); - } - const token = readAccessToken(); - if (token) { - headers.set("Authorization", `Bearer ${token}`); - } - return headers; -}; - -const send = async (path: string, init?: RequestInit): Promise => - fetch(`${API_BASE}${path}`, { - ...init, - headers: buildHeaders(init), - credentials: "include", - }); - -const parseBody = async (response: Response): Promise => { - if (response.status === 204) return undefined; - const text = await response.text(); - if (!text) return undefined; - try { - return JSON.parse(text); - } catch { - return text; - } -}; - const errorMessage = (body: unknown, fallback: string): string => { if (typeof body === "string" && body.length > 0) return body; if (body && typeof body === "object" && "message" in body) { @@ -104,43 +76,56 @@ const errorMessage = (body: unknown, fallback: string): string => { return fallback; }; -const request = async ( - path: string, - init?: RequestInit, - options: RequestOptions = {}, -): Promise => { - let response = await send(path, init); +http.interceptors.request.use((config) => { + const token = readAccessToken(); + if (token) { + config.headers.set("Authorization", `Bearer ${token}`); + } + return config; +}); + +http.interceptors.response.use( + (response) => { + // Match fetch's empty-body behaviour (204s parse to undefined, not ""). + if (response.data === "") { + response.data = undefined; + } + return response; + }, + async (error: AxiosError) => { + const { config, response } = error; + if (!response) throw error; - if (response.status === 401 && !options.skipAuthRetry) { - const refreshed = await refreshAccessToken(); - if (refreshed) { - response = await send(path, init); - } else { + if (response.status === 401 && config && !config.skipAuthRetry) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + return http.request(config); + } clearAuth(); } - } - if (!response.ok) { - const body = await parseBody(response); throw new ApiError( response.status, - errorMessage(body, response.statusText || "Request failed"), - body, + errorMessage(response.data, response.statusText || "Request failed"), + response.data, ); - } + }, +); - return (await parseBody(response)) as T; -}; +interface RequestOptions { + /** Skip the auto-refresh dance β€” used by the refresh / login calls themselves. */ + skipAuthRetry?: boolean; +} export const apiClient = { get: (path: string, options?: RequestOptions) => - request(path, undefined, options), + http.get(path, options).then((res) => res.data), post: (path: string, body: unknown, options?: RequestOptions) => - request(path, { method: "POST", body: JSON.stringify(body) }, options), + http.post(path, body, options).then((res) => res.data), postForm: (path: string, body: FormData, options?: RequestOptions) => - request(path, { method: "POST", body }, options), + http.post(path, body, options).then((res) => res.data), patch: (path: string, body: unknown, options?: RequestOptions) => - request(path, { method: "PATCH", body: JSON.stringify(body) }, options), + http.patch(path, body, options).then((res) => res.data), delete: (path: string, options?: RequestOptions) => - request(path, { method: "DELETE" }, options), + http.delete(path, options).then((res) => res.data), }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14ffcdb..8917138 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + axios: + specifier: ^1.17.0 + version: 1.17.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2250,6 +2253,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -2401,6 +2408,9 @@ packages: resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} engines: {node: '>=4'} + axios@1.17.0: + resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -3207,6 +3217,15 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -3410,6 +3429,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -4461,6 +4484,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -7328,6 +7355,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -7492,6 +7525,16 @@ snapshots: axe-core@4.11.4: {} + axios@1.17.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + axobject-query@4.1.0: {} babel-jest@30.4.1(@babel/core@7.29.7): @@ -8097,8 +8140,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.6 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) @@ -8124,7 +8167,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -8135,21 +8178,21 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8160,7 +8203,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -8460,6 +8503,8 @@ snapshots: flatted@3.4.2: {} + follow-redirects@1.16.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -8694,6 +8739,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} husky@9.1.7: {} @@ -9862,6 +9914,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + punycode@2.3.1: {} pure-rand@6.1.0: {} From 5092d47b97ef3f2172ea8742a6514763b766fe60 Mon Sep 17 00:00:00 2001 From: engdotme Date: Wed, 10 Jun 2026 13:40:03 -0400 Subject: [PATCH 09/31] refactor: wrap api-client axios instance in a singleton class --- frontend/src/lib/api-client.ts | 160 +++++++++++++++++++-------------- 1 file changed, 95 insertions(+), 65 deletions(-) diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 833efee..68a8a65 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -1,4 +1,9 @@ -import axios, { type AxiosError } from "axios"; +import axios, { + type AxiosError, + type AxiosInstance, + type AxiosResponse, + type InternalAxiosRequestConfig, +} from "axios"; import { API_BASE } from "@lib/config"; import { clearAuth, @@ -28,41 +33,10 @@ declare module "axios" { } } -const http = axios.create({ - baseURL: API_BASE, - withCredentials: true, -}); - -/** - * One in-flight refresh promise shared by every request that hits a 401. - * Without this guard a burst of parallel calls would each try to refresh, - * and reuse-detection on the backend would revoke the whole token family. - */ -let refreshPromise: Promise | null = null; - -const performRefresh = async (): Promise => { - try { - const { data } = await axios.post>( - `${API_BASE}/auth/refresh`, - undefined, - { withCredentials: true }, - ); - if (typeof data.accessToken !== "string") return null; - writeAccessToken(data.accessToken); - return data.accessToken; - } catch { - return null; - } -}; - -const refreshAccessToken = async (): Promise => { - if (!refreshPromise) { - refreshPromise = performRefresh().finally(() => { - refreshPromise = null; - }); - } - return refreshPromise; -}; +interface RequestOptions { + /** Skip the auto-refresh dance β€” used by the refresh / login calls themselves. */ + skipAuthRetry?: boolean; +} const errorMessage = (body: unknown, fallback: string): string => { if (typeof body === "string" && body.length > 0) return body; @@ -76,30 +50,58 @@ const errorMessage = (body: unknown, fallback: string): string => { return fallback; }; -http.interceptors.request.use((config) => { - const token = readAccessToken(); - if (token) { - config.headers.set("Authorization", `Bearer ${token}`); +class ApiClient { + private static instance: ApiClient | null = null; + + private readonly http: AxiosInstance; + + /** + * One in-flight refresh promise shared by every request that hits a 401. + * Without this guard a burst of parallel calls would each try to refresh, + * and reuse-detection on the backend would revoke the whole token family. + */ + private refreshPromise: Promise | null = null; + + private constructor() { + this.http = axios.create({ + baseURL: API_BASE, + withCredentials: true, + }); + this.http.interceptors.request.use(this.attachAuthHeader); + this.http.interceptors.response.use(this.normalizeEmptyBody, this.handleError); + } + + static getInstance(): ApiClient { + if (!ApiClient.instance) { + ApiClient.instance = new ApiClient(); + } + return ApiClient.instance; } - return config; -}); -http.interceptors.response.use( - (response) => { + private attachAuthHeader = (config: InternalAxiosRequestConfig) => { + const token = readAccessToken(); + if (token) { + config.headers.set("Authorization", `Bearer ${token}`); + } + return config; + }; + + private normalizeEmptyBody = (response: AxiosResponse) => { // Match fetch's empty-body behaviour (204s parse to undefined, not ""). if (response.data === "") { response.data = undefined; } return response; - }, - async (error: AxiosError) => { + }; + + private handleError = async (error: AxiosError) => { const { config, response } = error; if (!response) throw error; if (response.status === 401 && config && !config.skipAuthRetry) { - const refreshed = await refreshAccessToken(); + const refreshed = await this.refreshAccessToken(); if (refreshed) { - return http.request(config); + return this.http.request(config); } clearAuth(); } @@ -109,23 +111,51 @@ http.interceptors.response.use( errorMessage(response.data, response.statusText || "Request failed"), response.data, ); - }, -); + }; -interface RequestOptions { - /** Skip the auto-refresh dance β€” used by the refresh / login calls themselves. */ - skipAuthRetry?: boolean; + private async performRefresh(): Promise { + try { + const { data } = await axios.post>( + `${API_BASE}/auth/refresh`, + undefined, + { withCredentials: true }, + ); + if (typeof data.accessToken !== "string") return null; + writeAccessToken(data.accessToken); + return data.accessToken; + } catch { + return null; + } + } + + private refreshAccessToken(): Promise { + if (!this.refreshPromise) { + this.refreshPromise = this.performRefresh().finally(() => { + this.refreshPromise = null; + }); + } + return this.refreshPromise; + } + + get(path: string, options?: RequestOptions): Promise { + return this.http.get(path, options).then((res) => res.data); + } + + post(path: string, body: unknown, options?: RequestOptions): Promise { + return this.http.post(path, body, options).then((res) => res.data); + } + + postForm(path: string, body: FormData, options?: RequestOptions): Promise { + return this.http.post(path, body, options).then((res) => res.data); + } + + patch(path: string, body: unknown, options?: RequestOptions): Promise { + return this.http.patch(path, body, options).then((res) => res.data); + } + + delete(path: string, options?: RequestOptions): Promise { + return this.http.delete(path, options).then((res) => res.data); + } } -export const apiClient = { - get: (path: string, options?: RequestOptions) => - http.get(path, options).then((res) => res.data), - post: (path: string, body: unknown, options?: RequestOptions) => - http.post(path, body, options).then((res) => res.data), - postForm: (path: string, body: FormData, options?: RequestOptions) => - http.post(path, body, options).then((res) => res.data), - patch: (path: string, body: unknown, options?: RequestOptions) => - http.patch(path, body, options).then((res) => res.data), - delete: (path: string, options?: RequestOptions) => - http.delete(path, options).then((res) => res.data), -}; +export const apiClient = ApiClient.getInstance(); From 7d2042b9c106cb097baeb85d01a0b6178c8c7210 Mon Sep 17 00:00:00 2001 From: engdotme Date: Wed, 10 Jun 2026 16:24:25 -0400 Subject: [PATCH 10/31] feat: migrate auth to httpOnly cookies with server-side route protection --- DEPLOYMENT.md | 43 +++++- backend/.env.example | 3 + backend/src/config/config.types.ts | 1 + backend/src/config/configuration.ts | 2 + backend/src/config/env.validation.ts | 4 + backend/src/modules/auth/auth.controller.ts | 83 +++++++---- .../modules/auth/strategies/jwt.strategy.ts | 15 +- frontend/package.json | 1 + frontend/src/app/admin/layout.tsx | 5 +- frontend/src/app/apply/[step]/layout.tsx | 5 +- frontend/src/app/apply/status/page.tsx | 7 +- frontend/src/app/auth/callback/page.tsx | 63 ++------- frontend/src/app/student/layout.tsx | 9 +- frontend/src/features/auth/api.ts | 9 +- frontend/src/lib/api-client.ts | 40 ++---- frontend/src/lib/auth/access-token.ts | 37 +++++ frontend/src/lib/auth/index.ts | 1 - frontend/src/lib/auth/role-guard.tsx | 71 ---------- frontend/src/lib/session.ts | 21 --- .../providers/auth/current-user-provider.tsx | 19 +-- frontend/src/proxy.ts | 129 ++++++++++++++++++ pnpm-lock.yaml | 8 ++ 22 files changed, 328 insertions(+), 248 deletions(-) create mode 100644 frontend/src/lib/auth/access-token.ts delete mode 100644 frontend/src/lib/auth/role-guard.tsx create mode 100644 frontend/src/proxy.ts diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d5513c5..91b22a7 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -94,7 +94,42 @@ deploy you'll likely want to upgrade: Upgrades are a single click in the Render dashboard β€” no code changes. -## 3. After the first deploy +## 3. Auth cookies require a shared domain + +Auth is implemented with `httpOnly` cookies (`forgeng_access` / +`forgeng_refresh`) that the backend sets and the frontend's `middleware.ts` +reads to protect `/admin`, `/student`, and `/apply` routes. Cookies are only +visible to a server reading them if the cookie's domain matches the request's +domain. + +**This works automatically for local dev** (`localhost:3000` / +`localhost:3001`) and **for the default Vercel/Render setup is broken** β€” +`forgeng-frontend.vercel.app` and `forgeng-backend.onrender.com` are different +registrable domains, so the frontend's middleware will never see the cookies +the backend sets, and protected routes will redirect to `/sign-in` even when +the user just logged in. + +To fix this in production, put the frontend and backend on subdomains of the +same parent domain, e.g.: + +- Frontend: `app.example.com` (Vercel custom domain) +- Backend: `api.example.com` (Render custom domain) + +Then set on the backend: + +```env +REFRESH_COOKIE_DOMAIN=.example.com +ACCESS_COOKIE_NAME=forgeng_access # default, no change needed +``` + +(`ACCESS_COOKIE_NAME`'s cookie reuses `REFRESH_COOKIE_DOMAIN` for its +`Domain` attribute.) With both cookies scoped to `.example.com`, requests to +`app.example.com` include them and `middleware.ts` can verify the access +token. Also set `JWT_ACCESS_SECRET` on Vercel to the same value as the +backend's `JWT_ACCESS_SECRET`, since `middleware.ts` verifies the token +itself rather than calling the API. + +## 4. After the first deploy Verify the wiring end-to-end: @@ -111,7 +146,7 @@ open https:// If `/api/healthz` returns OK but the frontend can't reach the backend, the most common cause is a missing `CORS_ORIGIN` value on Render. -## 4. Optional: GitHub Actions +## 5. Optional: GitHub Actions The repo intentionally ships **without** a CI workflow. Vercel and Render both run their own build on every push, so any breaking change still gets @@ -131,7 +166,7 @@ A reasonable starter `ci.yml` (frontend + backend lint + build, parallel jobs, pnpm cache, 15-min timeout) takes ~70 lines. Ask whenever you want one wired up. -## 5. Local environment files +## 6. Local environment files For reference, here's what each environment needs: @@ -140,6 +175,7 @@ For reference, here's what each environment needs: ```env NEXT_PUBLIC_API_URL=http://localhost:3001 NEXT_PUBLIC_SITE_URL=http://localhost:3000 +JWT_ACCESS_SECRET=dev-access-secret-change-me ``` ### `backend/.env` @@ -148,6 +184,7 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/forgeng?schema=public CORS_ORIGIN=http://localhost:3000 PORT=3001 +JWT_ACCESS_SECRET=dev-access-secret-change-me ``` Vercel and Render hold the production equivalents in their own diff --git a/backend/.env.example b/backend/.env.example index 352f53a..4f1bdd3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,6 +32,9 @@ REFRESH_COOKIE_NAME="forgeng_refresh" # Optional cookie domain (leave unset for localhost). REFRESH_COOKIE_DOMAIN= +# Name of the cookie holding the access token. +ACCESS_COOKIE_NAME="forgeng_access" + # How long an email verification token stays valid (minutes). Default 1440 (24h). EMAIL_VERIFY_TTL_MINUTES=1440 # How long a password reset token stays valid (minutes). Default 60 (1h). diff --git a/backend/src/config/config.types.ts b/backend/src/config/config.types.ts index 744ea0f..0fb10f8 100644 --- a/backend/src/config/config.types.ts +++ b/backend/src/config/config.types.ts @@ -17,6 +17,7 @@ export interface AppConfiguration { refreshTtl: string; refreshCookieName: string; refreshCookieDomain: string | undefined; + accessCookieName: string; verifyTokenTtlMinutes: number; passwordResetTtlMinutes: number; oauthSuccessRedirect: string; diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 6f5d4c3..119641a 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -12,6 +12,7 @@ const DEFAULT_FRONTEND = 'http://localhost:3000'; const DEFAULT_ACCESS_TTL = '15m'; const DEFAULT_REFRESH_TTL = '7d'; const DEFAULT_REFRESH_COOKIE = 'forgeng_refresh'; +const DEFAULT_ACCESS_COOKIE = 'forgeng_access'; const DEFAULT_VERIFY_TTL_MINUTES = 60 * 24; // 24h const DEFAULT_PASSWORD_RESET_TTL_MINUTES = 60; // 1h β€” reset links are short-lived const DEFAULT_EMAIL_FROM = 'no-reply@forgeng.local'; @@ -93,6 +94,7 @@ export default (): AppConfiguration => { refreshCookieName: process.env.REFRESH_COOKIE_NAME ?? DEFAULT_REFRESH_COOKIE, refreshCookieDomain: process.env.REFRESH_COOKIE_DOMAIN, + accessCookieName: process.env.ACCESS_COOKIE_NAME ?? DEFAULT_ACCESS_COOKIE, verifyTokenTtlMinutes: parsePort( process.env.EMAIL_VERIFY_TTL_MINUTES, DEFAULT_VERIFY_TTL_MINUTES, diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts index d01b3f2..bbc5e7a 100644 --- a/backend/src/config/env.validation.ts +++ b/backend/src/config/env.validation.ts @@ -65,6 +65,10 @@ class EnvironmentVariables { @IsString() REFRESH_COOKIE_DOMAIN?: string; + @IsOptional() + @IsString() + ACCESS_COOKIE_NAME?: string; + @IsOptional() @IsInt() EMAIL_VERIFY_TTL_MINUTES?: number; diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 6727ab7..2e59333 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -37,11 +37,6 @@ interface OAuthRequest extends Request { user: OAuthProfileDto; } -interface ClientTokens { - accessToken: string; - expiresIn: number; -} - @ApiTags('auth') @Controller('auth') export class AuthController { @@ -69,7 +64,7 @@ export class AuthController { @Body() dto: LoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response, - ): Promise<{ user: UserDto } & ClientTokens> { + ): Promise<{ user: UserDto }> { const result = await this.service.login(dto, ctxFromRequest(req)); return this.respondWithSession(res, result); } @@ -80,11 +75,17 @@ export class AuthController { async refresh( @Req() req: Request, @Res({ passthrough: true }) res: Response, - ): Promise<{ user: UserDto } & ClientTokens> { + ): Promise<{ user: UserDto }> { const token = this.readRefreshCookie(req); if (!token) throw new BadRequestException('Missing refresh token.'); - const result = await this.service.refresh(token, ctxFromRequest(req)); - return this.respondWithSession(res, result); + try { + const result = await this.service.refresh(token, ctxFromRequest(req)); + return this.respondWithSession(res, result); + } catch (err) { + this.clearRefreshCookie(res); + this.clearAccessCookie(res); + throw err; + } } @Public() @@ -97,6 +98,7 @@ export class AuthController { const token = this.readRefreshCookie(req); await this.service.logout(token); this.clearRefreshCookie(res); + this.clearAccessCookie(res); } @Public() @@ -182,15 +184,15 @@ export class AuthController { result.tokens.refreshToken, result.tokens.refreshExpiresAt, ); - const target = new URL( - this.config.getOrThrow('auth.oauthSuccessRedirect', { infer: true }), - ); - target.searchParams.set('accessToken', result.tokens.accessToken); - target.searchParams.set( - 'expiresIn', - String(result.tokens.accessExpiresIn), + this.setAccessCookie( + res, + result.tokens.accessToken, + result.tokens.accessExpiresIn, ); - res.redirect(target.toString()); + const target = this.config.getOrThrow('auth.oauthSuccessRedirect', { + infer: true, + }); + res.redirect(target); } catch { const failureUrl = this.config.getOrThrow('auth.oauthFailureRedirect', { infer: true, @@ -202,17 +204,18 @@ export class AuthController { private respondWithSession( res: Response, result: AuthResult, - ): { user: UserDto } & ClientTokens { + ): { user: UserDto } { this.setRefreshCookie( res, result.tokens.refreshToken, result.tokens.refreshExpiresAt, ); - return { - user: result.user, - accessToken: result.tokens.accessToken, - expiresIn: result.tokens.accessExpiresIn, - }; + this.setAccessCookie( + res, + result.tokens.accessToken, + result.tokens.accessExpiresIn, + ); + return { user: result.user }; } private setRefreshCookie( @@ -231,7 +234,9 @@ export class AuthController { secure: isProd, sameSite: 'lax', domain: domain ?? undefined, - path: '/api/auth', + // Path "/" (not "/api/auth") so the frontend's proxy.ts receives this + // cookie on page navigations and can forward it to /auth/refresh. + path: '/', expires: expiresAt, }); } @@ -240,7 +245,35 @@ export class AuthController { const cookieName = this.config.getOrThrow('auth.refreshCookieName', { infer: true, }); - res.clearCookie(cookieName, { path: '/api/auth' }); + res.clearCookie(cookieName, { path: '/' }); + } + + private setAccessCookie( + res: Response, + token: string, + expiresInSeconds: number, + ): void { + const cookieName = this.config.getOrThrow('auth.accessCookieName', { + infer: true, + }); + const domain = this.config.get('auth.refreshCookieDomain', { infer: true }); + const isProd = + this.config.getOrThrow('nodeEnv', { infer: true }) === 'production'; + res.cookie(cookieName, token, { + httpOnly: true, + secure: isProd, + sameSite: 'lax', + domain: domain ?? undefined, + path: '/', + expires: new Date(Date.now() + expiresInSeconds * 1000), + }); + } + + private clearAccessCookie(res: Response): void { + const cookieName = this.config.getOrThrow('auth.accessCookieName', { + infer: true, + }); + res.clearCookie(cookieName, { path: '/' }); } private readRefreshCookie(req: Request): string | undefined { diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts index d861dd9..68c83e9 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import type { Request } from 'express'; import type { AppConfiguration } from '@config'; import { PrismaService } from '@core/database/prisma.service'; @@ -14,8 +15,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { config: ConfigService, private readonly prisma: PrismaService, ) { + const accessCookieName = config.getOrThrow('auth.accessCookieName', { + infer: true, + }); + const cookieExtractor = (req: Request): string | null => { + const cookies: unknown = (req as Request & { cookies?: unknown }).cookies; + if (!cookies || typeof cookies !== 'object') return null; + const value = (cookies as Record)[accessCookieName]; + return typeof value === 'string' ? value : null; + }; super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: ExtractJwt.fromExtractors([ + ExtractJwt.fromAuthHeaderAsBearerToken(), + cookieExtractor, + ]), ignoreExpiration: false, secretOrKey: config.getOrThrow('auth.accessSecret', { infer: true }), }); diff --git a/frontend/package.json b/frontend/package.json index 3ca3fe2..4875c5d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.3.0", + "jose": "^6.2.3", "lucide-react": "^1.16.0", "next": "16.2.6", "next-themes": "^0.4.6", diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index 10b29dd..b34e5b9 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -1,10 +1,7 @@ import { SidebarLayout } from "@components/layout/sidebar-layout"; -import { RoleGuard } from "@lib/auth"; const Layout = ({ children }: { children: React.ReactNode }) => ( - - {children} - + {children} ); export default Layout; diff --git a/frontend/src/app/apply/[step]/layout.tsx b/frontend/src/app/apply/[step]/layout.tsx index 148e564..9fbef6b 100644 --- a/frontend/src/app/apply/[step]/layout.tsx +++ b/frontend/src/app/apply/[step]/layout.tsx @@ -3,12 +3,9 @@ import type { ReactNode } from "react"; import { Wizard } from "@features/applications"; -import { RoleGuard } from "@lib/auth"; const ApplyStepLayout = ({ children }: { children: ReactNode }) => ( - - {children} - + {children} ); export default ApplyStepLayout; diff --git a/frontend/src/app/apply/status/page.tsx b/frontend/src/app/apply/status/page.tsx index cec294a..c4814c8 100644 --- a/frontend/src/app/apply/status/page.tsx +++ b/frontend/src/app/apply/status/page.tsx @@ -16,7 +16,6 @@ import { import { Skeleton } from "@components/ui/skeleton"; import { useCurrentUser } from "@contexts"; import { getMyApplication, StatusBadge } from "@features/applications"; -import { RoleGuard } from "@lib/auth"; import { useAsyncResource } from "@hooks/use-async-resource"; import type { ApplicationStatus } from "@types"; @@ -109,10 +108,6 @@ const StatusInner = () => { ); }; -const Page = () => ( - - - -); +const Page = () => ; export default Page; diff --git a/frontend/src/app/auth/callback/page.tsx b/frontend/src/app/auth/callback/page.tsx index 9c26c40..7a8bfb2 100644 --- a/frontend/src/app/auth/callback/page.tsx +++ b/frontend/src/app/auth/callback/page.tsx @@ -1,61 +1,18 @@ -"use client"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; -import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense, useEffect } from "react"; -import { Loader2 } from "lucide-react"; -import { toast } from "sonner"; - -import { Logo } from "@components/brand/logo"; -import { getMe } from "@features/auth"; -import { writeAccessToken } from "@lib/session"; +import { ACCESS_COOKIE_NAME, verifyAccessToken } from "@lib/auth/access-token"; import { homeForRole } from "@utils/auth"; -const CallbackInner = () => { - const router = useRouter(); - const params = useSearchParams(); - - useEffect(() => { - const accessToken = params.get("accessToken"); - if (!accessToken) { - toast.error("Could not complete sign-in."); - router.replace("/sign-in"); - return; - } +const Page = async () => { + const token = (await cookies()).get(ACCESS_COOKIE_NAME)?.value; + const payload = token ? await verifyAccessToken(token) : null; - let cancelled = false; - writeAccessToken(accessToken); - void (async () => { - try { - const user = await getMe(); - if (cancelled) return; - toast.success(`Signed in as ${user.name ?? user.email}.`); - router.replace(homeForRole(user.role)); - } catch { - if (cancelled) return; - toast.error("Sign-in succeeded but loading your profile failed."); - router.replace("/sign-in"); - } - })(); - return () => { - cancelled = true; - }; - }, [params, router]); + if (!payload) { + redirect("/sign-in"); + } - return ( -
-
- - -

Finishing sign-in…

-
-
- ); + redirect(homeForRole(payload.role)); }; -const Page = () => ( - - - -); - export default Page; diff --git a/frontend/src/app/student/layout.tsx b/frontend/src/app/student/layout.tsx index 8464a94..952da92 100644 --- a/frontend/src/app/student/layout.tsx +++ b/frontend/src/app/student/layout.tsx @@ -1,13 +1,10 @@ import { SidebarLayout } from "@components/layout/sidebar-layout"; -import { RoleGuard } from "@lib/auth"; import { SelectedCohortProvider } from "@providers"; const Layout = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - + + {children} + ); export default Layout; diff --git a/frontend/src/features/auth/api.ts b/frontend/src/features/auth/api.ts index dbd2d0f..ce1181e 100644 --- a/frontend/src/features/auth/api.ts +++ b/frontend/src/features/auth/api.ts @@ -1,10 +1,6 @@ import { apiClient } from "@lib/api-client"; import { API_BASE } from "@lib/config"; -import { - clearAuth, - writeAccessToken, - writeSession, -} from "@lib/session"; +import { clearAuth, writeSession } from "@lib/session"; import type { UserProfile } from "@types"; import { normalizeEmail } from "@utils/auth"; import { mapUserDto, type UserDto } from "@utils/user"; @@ -13,8 +9,6 @@ export type OAuthProvider = "google" | "github"; interface AuthSessionResponse { user: UserDto; - accessToken: string; - expiresIn: number; } interface UserResponse { @@ -22,7 +16,6 @@ interface UserResponse { } const persistSession = (response: AuthSessionResponse): UserProfile => { - writeAccessToken(response.accessToken); const profile = mapUserDto(response.user); writeSession(profile); return profile; diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 68a8a65..997edb8 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -2,14 +2,9 @@ import axios, { type AxiosError, type AxiosInstance, type AxiosResponse, - type InternalAxiosRequestConfig, } from "axios"; import { API_BASE } from "@lib/config"; -import { - clearAuth, - readAccessToken, - writeAccessToken, -} from "@lib/session"; +import { clearAuth } from "@lib/session"; export class ApiError extends Error { constructor( @@ -22,10 +17,6 @@ export class ApiError extends Error { } } -interface RefreshSuccess { - accessToken: string; -} - declare module "axios" { export interface AxiosRequestConfig { /** Skip the auto-refresh dance β€” used by the refresh / login calls themselves. */ @@ -60,14 +51,13 @@ class ApiClient { * Without this guard a burst of parallel calls would each try to refresh, * and reuse-detection on the backend would revoke the whole token family. */ - private refreshPromise: Promise | null = null; + private refreshPromise: Promise | null = null; private constructor() { this.http = axios.create({ baseURL: API_BASE, withCredentials: true, }); - this.http.interceptors.request.use(this.attachAuthHeader); this.http.interceptors.response.use(this.normalizeEmptyBody, this.handleError); } @@ -78,14 +68,6 @@ class ApiClient { return ApiClient.instance; } - private attachAuthHeader = (config: InternalAxiosRequestConfig) => { - const token = readAccessToken(); - if (token) { - config.headers.set("Authorization", `Bearer ${token}`); - } - return config; - }; - private normalizeEmptyBody = (response: AxiosResponse) => { // Match fetch's empty-body behaviour (204s parse to undefined, not ""). if (response.data === "") { @@ -113,22 +95,18 @@ class ApiClient { ); }; - private async performRefresh(): Promise { + private async performRefresh(): Promise { try { - const { data } = await axios.post>( - `${API_BASE}/auth/refresh`, - undefined, - { withCredentials: true }, - ); - if (typeof data.accessToken !== "string") return null; - writeAccessToken(data.accessToken); - return data.accessToken; + await axios.post(`${API_BASE}/auth/refresh`, undefined, { + withCredentials: true, + }); + return true; } catch { - return null; + return false; } } - private refreshAccessToken(): Promise { + private refreshAccessToken(): Promise { if (!this.refreshPromise) { this.refreshPromise = this.performRefresh().finally(() => { this.refreshPromise = null; diff --git a/frontend/src/lib/auth/access-token.ts b/frontend/src/lib/auth/access-token.ts new file mode 100644 index 0000000..6facc3f --- /dev/null +++ b/frontend/src/lib/auth/access-token.ts @@ -0,0 +1,37 @@ +import { jwtVerify } from "jose"; + +import type { UserRole } from "@types"; + +export const ACCESS_COOKIE_NAME = "forgeng_access"; + +export interface AccessTokenPayload { + sub: number; + email: string; + role: UserRole; +} + +/** Verifies an access token cookie. Returns `null` on any verification failure. */ +export const verifyAccessToken = async ( + token: string, +): Promise => { + const secret = process.env.JWT_ACCESS_SECRET; + if (!secret) return null; + try { + const { payload } = await jwtVerify(token, new TextEncoder().encode(secret)); + if ( + typeof payload.sub !== "string" && typeof payload.sub !== "number" + ) { + return null; + } + if (typeof payload.email !== "string" || typeof payload.role !== "string") { + return null; + } + return { + sub: Number(payload.sub), + email: payload.email, + role: payload.role as UserRole, + }; + } catch { + return null; + } +}; diff --git a/frontend/src/lib/auth/index.ts b/frontend/src/lib/auth/index.ts index 461d459..520457b 100644 --- a/frontend/src/lib/auth/index.ts +++ b/frontend/src/lib/auth/index.ts @@ -1,2 +1 @@ export { homeForRole } from "@utils/auth"; -export { RoleGuard } from "./role-guard"; diff --git a/frontend/src/lib/auth/role-guard.tsx b/frontend/src/lib/auth/role-guard.tsx deleted file mode 100644 index 267bf91..0000000 --- a/frontend/src/lib/auth/role-guard.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; - -import { Skeleton } from "@components/ui/skeleton"; -import { useCurrentUser } from "@contexts"; -import type { UserRole } from "@types"; - -import { homeForRole } from "@utils/auth"; - -interface RoleGuardProps { - allowedRoles: UserRole[]; - children: React.ReactNode; - /** Where to send signed-out visitors. Defaults to `/sign-in`. */ - signInPath?: string; -} - -/** - * Client-side route guard. Mount it inside a layout to protect every page - * below it. - * - * - Signed-out users are redirected to `signInPath`. - * - Wrong-role users are bounced to their own role's home page with a toast. - * - During hydration, a small skeleton is rendered to avoid the flash of - * "redirecting" content before localStorage has been read. - * - * The guard is intentionally cosmetic: real authorization happens on the - * NestJS side. This is just to keep navigation honest. - */ -export function RoleGuard({ - allowedRoles, - children, - signInPath = "/sign-in", -}: RoleGuardProps) { - const { user, isHydrated } = useCurrentUser(); - const router = useRouter(); - - const allowed = user != null && allowedRoles.includes(user.role); - - useEffect(() => { - if (!isHydrated) return; - if (!user) { - router.replace(signInPath); - return; - } - if (!allowedRoles.includes(user.role)) { - toast.error("You don't have access to that page."); - router.replace(homeForRole(user.role)); - } - }, [isHydrated, user, allowedRoles, router, signInPath]); - - if (!isHydrated || !allowed) { - return ; - } - return <>{children}; -} - -function RoleGuardFallback() { - return ( -
- -
- - -
- -
- ); -} diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts index 7b9cf42..4c567b5 100644 --- a/frontend/src/lib/session.ts +++ b/frontend/src/lib/session.ts @@ -1,7 +1,6 @@ import type { UserProfile } from "@types"; const SESSION_KEY = "forgeng.session"; -const ACCESS_TOKEN_KEY = "forgeng.accessToken"; /** Cached snapshot so useSyncExternalStore getSnapshot stays referentially stable. */ let cachedRaw: string | null | undefined; @@ -55,27 +54,7 @@ export const subscribeSession = (callback: () => void): (() => void) => { }; }; -/** - * Access token storage. The token also lives in localStorage so a hard reload - * doesn't bounce the user back to /sign-in before the refresh-cookie round - * trip β€” but it is short-lived (15m by default) and rotated via /auth/refresh. - */ -export const readAccessToken = (): string | null => { - if (typeof window === "undefined") return null; - return window.localStorage.getItem(ACCESS_TOKEN_KEY); -}; - -export const writeAccessToken = (token: string | null): void => { - if (typeof window === "undefined") return; - if (token === null) { - window.localStorage.removeItem(ACCESS_TOKEN_KEY); - } else { - window.localStorage.setItem(ACCESS_TOKEN_KEY, token); - } -}; - /** Clear everything β€” used by signOut and on hard auth failures. */ export const clearAuth = (): void => { - writeAccessToken(null); writeSession(null); }; diff --git a/frontend/src/providers/auth/current-user-provider.tsx b/frontend/src/providers/auth/current-user-provider.tsx index d66a7e1..1e43f91 100644 --- a/frontend/src/providers/auth/current-user-provider.tsx +++ b/frontend/src/providers/auth/current-user-provider.tsx @@ -14,11 +14,7 @@ import { type OAuthProvider, } from "@features/auth"; import { ApiError } from "@lib/api-client"; -import { - readAccessToken, - readSession, - subscribeSession, -} from "@lib/session"; +import { readSession, subscribeSession } from "@lib/session"; import type { UserProfile } from "@types"; const getUserSnapshot = (): UserProfile | null => readSession(); @@ -77,16 +73,11 @@ export const CurrentUserProvider = ({ window.location.assign(oauthStartUrl(provider)); }, []); - // First-mount rehydrate: if we have an access token in localStorage, confirm - // it's still valid by calling /auth/me. The api-client transparently rotates - // via /auth/refresh on a 401, so a successful response also means the - // refresh cookie is still good. + // First-mount rehydrate: the access token lives in an httpOnly cookie, so + // confirm it's still valid by calling /auth/me. The api-client transparently + // rotates via /auth/refresh on a 401, so a successful response also means + // the refresh cookie is still good. useEffect(() => { - if (typeof window === "undefined") return; - const hasToken = readAccessToken() !== null; - const hasUser = readSession() !== null; - if (!hasToken && !hasUser) return; - let cancelled = false; void (async () => { try { diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts new file mode 100644 index 0000000..854715b --- /dev/null +++ b/frontend/src/proxy.ts @@ -0,0 +1,129 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { + ACCESS_COOKIE_NAME, + verifyAccessToken, + type AccessTokenPayload, +} from "@lib/auth/access-token"; +import { homeForRole } from "@utils/auth"; +import type { UserRole } from "@types"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; + +const PROTECTED_ROUTES: { prefix: string; roles: UserRole[] }[] = [ + { prefix: "/admin", roles: ["admin"] }, + { prefix: "/student", roles: ["student"] }, + { prefix: "/apply", roles: ["applicant"] }, +]; + +const AUTH_PAGES = ["/sign-in", "/sign-up", "/forgot-password"]; + +const matchesPrefix = (pathname: string, prefix: string): boolean => + pathname === prefix || pathname.startsWith(`${prefix}/`); + +export async function proxy(req: NextRequest) { + const { pathname } = req.nextUrl; + + const protectedRoute = PROTECTED_ROUTES.find((route) => + matchesPrefix(pathname, route.prefix), + ); + const isAuthPage = AUTH_PAGES.some((page) => matchesPrefix(pathname, page)); + + if (!protectedRoute && !isAuthPage) { + return NextResponse.next(); + } + + let payload: AccessTokenPayload | null = null; + let setCookieHeaders: string[] = []; + + const accessToken = req.cookies.get(ACCESS_COOKIE_NAME)?.value; + if (accessToken) { + payload = await verifyAccessToken(accessToken); + } + + if (!payload) { + const cookieHeader = req.headers.get("cookie"); + if (cookieHeader) { + const refreshed = await attemptRefresh(cookieHeader); + if (refreshed) { + payload = refreshed.payload; + setCookieHeaders = refreshed.setCookieHeaders; + } + } + } + + if (protectedRoute) { + if (!payload) { + return redirectWithCookies(req, "/sign-in", setCookieHeaders); + } + if (!protectedRoute.roles.includes(payload.role)) { + return redirectWithCookies(req, homeForRole(payload.role), setCookieHeaders); + } + } + + if (isAuthPage && payload) { + return redirectWithCookies(req, homeForRole(payload.role), setCookieHeaders); + } + + const res = NextResponse.next(); + applySetCookies(res, setCookieHeaders); + return res; +} + +interface RefreshResult { + payload: AccessTokenPayload; + setCookieHeaders: string[]; +} + +/** Forwards the request's cookies to /auth/refresh and verifies the rotated access token. */ +async function attemptRefresh(cookieHeader: string): Promise { + try { + const res = await fetch(`${API_URL}/api/auth/refresh`, { + method: "POST", + headers: { cookie: cookieHeader }, + }); + if (!res.ok) return null; + + const setCookieHeaders = res.headers.getSetCookie(); + const accessToken = setCookieHeaders + .map((header) => readCookieValue(header, ACCESS_COOKIE_NAME)) + .find((value): value is string => value != null); + if (!accessToken) return null; + + const payload = await verifyAccessToken(accessToken); + if (!payload) return null; + + return { payload, setCookieHeaders }; + } catch { + return null; + } +} + +function readCookieValue(setCookieHeader: string, name: string): string | null { + const [pair] = setCookieHeader.split(";"); + const separatorIndex = pair.indexOf("="); + if (separatorIndex === -1) return null; + const key = pair.slice(0, separatorIndex).trim(); + if (key !== name) return null; + return pair.slice(separatorIndex + 1).trim(); +} + +function redirectWithCookies( + req: NextRequest, + path: string, + setCookieHeaders: string[], +): NextResponse { + const res = NextResponse.redirect(new URL(path, req.url)); + applySetCookies(res, setCookieHeaders); + return res; +} + +function applySetCookies(res: NextResponse, setCookieHeaders: string[]): void { + for (const header of setCookieHeaders) { + res.headers.append("Set-Cookie", header); + } +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8917138..efadc7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,9 @@ importers: date-fns: specifier: ^4.3.0 version: 4.3.0 + jose: + specifier: ^6.2.3 + version: 6.2.3 lucide-react: specifier: ^1.16.0 version: 1.16.0(react@19.2.4) @@ -3799,6 +3802,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -9295,6 +9301,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: From 57eb5b6b89c216a75a2bae106b4e853a4939dcab Mon Sep 17 00:00:00 2001 From: engdotme Date: Wed, 10 Jun 2026 19:22:14 -0400 Subject: [PATCH 11/31] fix: retry 401 requests only once after token refresh --- frontend/src/lib/api-client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 997edb8..ef38ec4 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -83,7 +83,8 @@ class ApiClient { if (response.status === 401 && config && !config.skipAuthRetry) { const refreshed = await this.refreshAccessToken(); if (refreshed) { - return this.http.request(config); + // Retry once; skipAuthRetry stops a second 401 from looping forever. + return this.http.request({ ...config, skipAuthRetry: true }); } clearAuth(); } From 7217bfb4a38066e63be0c1befce08e53efe24446 Mon Sep 17 00:00:00 2001 From: engdotme Date: Wed, 10 Jun 2026 19:25:37 -0400 Subject: [PATCH 12/31] fix: only render user-supplied URLs as links when they are http(s) --- .../[id]/_components/payment-stats-chart.tsx | 3 ++- .../components/common/external-link-field.tsx | 24 ++++++++++++------- .../components/application-detail-page.tsx | 23 +++++++++++------- .../applications/components/detail-dialog.tsx | 3 ++- frontend/src/utils/index.ts | 1 + frontend/src/utils/url.ts | 5 ++++ 6 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 frontend/src/utils/url.ts diff --git a/frontend/src/app/admin/users/[id]/_components/payment-stats-chart.tsx b/frontend/src/app/admin/users/[id]/_components/payment-stats-chart.tsx index 3d70abb..3637240 100644 --- a/frontend/src/app/admin/users/[id]/_components/payment-stats-chart.tsx +++ b/frontend/src/app/admin/users/[id]/_components/payment-stats-chart.tsx @@ -3,6 +3,7 @@ import { format, parse } from "date-fns"; import { CheckCircle2 } from "lucide-react"; import type { MonthlyPaymentStat } from "@features/users/api"; +import { isSafeHref } from "@utils"; export function PaymentStatsChart({ stats }: { stats: MonthlyPaymentStat[] }) { return ( @@ -54,7 +55,7 @@ export function PaymentStatsChart({ stats }: { stats: MonthlyPaymentStat[] }) { {stat.payment.amount} {stat.payment.currency} paid - {stat.payment.txLink && ( + {stat.payment.txLink && isSafeHref(stat.payment.txLink) && ( (
{title} - - - {href} - + {isSafeHref(href) ? ( + + + {href} + + ) : ( +

{href}

+ )}
); diff --git a/frontend/src/features/applications/components/application-detail-page.tsx b/frontend/src/features/applications/components/application-detail-page.tsx index 3c112e5..684d942 100644 --- a/frontend/src/features/applications/components/application-detail-page.tsx +++ b/frontend/src/features/applications/components/application-detail-page.tsx @@ -22,6 +22,7 @@ import { APPLICATION_STATUS_OPTIONS } from "@constants/applications"; import { countryLabel } from "@constants/shared/countries"; import { useAsyncResource } from "@hooks/use-async-resource"; import { resolveAssetUrl } from "@lib/config"; +import { isSafeHref } from "@utils"; import type { ApplicationStatus } from "@types"; import { getApplication } from "../api"; import { useUpdateApplicationStatus } from "../hooks"; @@ -179,14 +180,20 @@ export const ApplicationDetailPage = ({ id }: Props) => { ({ key, label }) => (
{label} - - {application[key]} - + {isSafeHref(application[key]!) ? ( + + {application[key]} + + ) : ( + + {application[key]} + + )}
), )} diff --git a/frontend/src/features/applications/components/detail-dialog.tsx b/frontend/src/features/applications/components/detail-dialog.tsx index 4286624..161ec73 100644 --- a/frontend/src/features/applications/components/detail-dialog.tsx +++ b/frontend/src/features/applications/components/detail-dialog.tsx @@ -23,6 +23,7 @@ import { import { Textarea } from "@components/ui/textarea"; import { APPLICATION_STATUS_OPTIONS } from "@constants/applications"; import { useCohorts } from "@features/cohorts"; +import { isSafeHref } from "@utils"; import { useUpdateApplicationStatus } from "../hooks"; import type { Application, ApplicationStatus } from "@types"; @@ -124,7 +125,7 @@ export const DetailDialog = ({ { key: "portfolio", label: "Portfolio" }, ] as const ) - .filter(({ key }) => !!application[key]) + .filter(({ key }) => isSafeHref(application[key] ?? "")) .map(({ key, label }) => ( /^https?:\/\//i.test(href); From fb360547ad4446ad579670f1d4716d8c6a84ee59 Mon Sep 17 00:00:00 2001 From: engdotme Date: Wed, 10 Jun 2026 19:40:34 -0400 Subject: [PATCH 13/31] refactor: keep registration ip/location out of the localStorage --- frontend/src/features/users/api.ts | 11 ++++++----- .../src/features/users/components/profile-sheet.tsx | 4 ++-- frontend/src/features/users/types.ts | 2 +- frontend/src/lib/utils.ts | 2 -- frontend/src/types/index.ts | 7 ++++++- frontend/src/types/user.ts | 7 +++++++ frontend/src/utils/user.ts | 6 ------ 7 files changed, 22 insertions(+), 17 deletions(-) delete mode 100644 frontend/src/lib/utils.ts diff --git a/frontend/src/features/users/api.ts b/frontend/src/features/users/api.ts index 30ca2c9..99deaac 100644 --- a/frontend/src/features/users/api.ts +++ b/frontend/src/features/users/api.ts @@ -1,7 +1,7 @@ import { apiClient } from "@lib/api-client"; import type { Cohort } from "@types"; -import type { UserProfile, UserRole } from "./types"; +import type { AdminUserDetail, UserRole } from "./types"; export interface UserEnrollment { id: number; @@ -10,7 +10,7 @@ export interface UserEnrollment { } export interface PaginatedUsers { - items: UserProfile[]; + items: AdminUserDetail[]; total: number; page: number; pageSize: number; @@ -62,8 +62,8 @@ export interface UserPaymentStats { monthlyStats: MonthlyPaymentStat[]; } -export const getUser = async (id: number): Promise => - apiClient.get(`/users/${id}`); +export const getUser = async (id: number): Promise => + apiClient.get(`/users/${id}`); export const getUserPaymentStats = async ( id: number, @@ -84,4 +84,5 @@ export const recordPayment = async ( export const updateUserRole = async ( id: number, role: UserRole, -): Promise => apiClient.patch(`/users/${id}/role`, { role }); +): Promise => + apiClient.patch(`/users/${id}/role`, { role }); diff --git a/frontend/src/features/users/components/profile-sheet.tsx b/frontend/src/features/users/components/profile-sheet.tsx index 8f8b585..a32a7ae 100644 --- a/frontend/src/features/users/components/profile-sheet.tsx +++ b/frontend/src/features/users/components/profile-sheet.tsx @@ -13,12 +13,12 @@ import { import { Badge } from "@components/ui/badge"; import { COHORT_STATUS_VARIANT } from "@constants/cohorts"; import { resolveAssetUrl } from "@lib/config"; -import type { UserProfile } from "@types"; +import type { AdminUserDetail } from "@types"; import { useUserEnrollments } from "../hooks"; export type ProfileSheetProps = { - user: UserProfile; + user: AdminUserDetail; open: boolean; onClose: () => void; }; diff --git a/frontend/src/features/users/types.ts b/frontend/src/features/users/types.ts index 64ae736..2e9f363 100644 --- a/frontend/src/features/users/types.ts +++ b/frontend/src/features/users/types.ts @@ -1 +1 @@ -export type { UserProfile, UserRole } from "@types"; +export type { AdminUserDetail, UserProfile, UserRole } from "@types"; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts deleted file mode 100644 index bfefd87..0000000 --- a/frontend/src/lib/utils.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** @deprecated Import from `@utils` instead. */ -export { cn } from "@utils/cn"; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4984110..2916f38 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,4 +1,9 @@ -export type { UserRole, UserProfile, UserRoleFilter } from "./user"; +export type { + AdminUserDetail, + UserRole, + UserProfile, + UserRoleFilter, +} from "./user"; export type { Application, ApplicationStatus, diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index f79ff50..92a4542 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -20,6 +20,13 @@ export interface UserProfile { portfolio: string | null; telegram: string | null; whatsapp: string | null; +} + +/** + * Admin-only view of a user. Registration forensics stay out of UserProfile + * so they are never persisted to the localStorage session. + */ +export interface AdminUserDetail extends UserProfile { registrationIp: string | null; registrationCountry: string | null; registrationCity: string | null; diff --git a/frontend/src/utils/user.ts b/frontend/src/utils/user.ts index e2f7461..046dfa8 100644 --- a/frontend/src/utils/user.ts +++ b/frontend/src/utils/user.ts @@ -43,9 +43,6 @@ export interface UserDto { portfolio: string | null; telegram: string | null; whatsapp: string | null; - registrationIp: string | null; - registrationCountry: string | null; - registrationCity: string | null; } export const mapUserDto = (dto: UserDto): UserProfile => ({ @@ -65,7 +62,4 @@ export const mapUserDto = (dto: UserDto): UserProfile => ({ portfolio: dto.portfolio, telegram: dto.telegram, whatsapp: dto.whatsapp, - registrationIp: dto.registrationIp, - registrationCountry: dto.registrationCountry, - registrationCity: dto.registrationCity, }); From 8749f9c90ac699ae43699cbab16c0680be9013bf Mon Sep 17 00:00:00 2001 From: engdotme Date: Wed, 10 Jun 2026 21:12:35 -0400 Subject: [PATCH 14/31] fix: clear user-scoped localStorage on sign-out --- .../src/constants/applications/apply-form.ts | 3 ++- frontend/src/lib/session.ts | 27 +++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/frontend/src/constants/applications/apply-form.ts b/frontend/src/constants/applications/apply-form.ts index 84e2ad7..0052ed6 100644 --- a/frontend/src/constants/applications/apply-form.ts +++ b/frontend/src/constants/applications/apply-form.ts @@ -101,7 +101,8 @@ export const APPLICATION_FORM_SCHEMA = z.object({ export type ApplicationFormValues = z.infer; -export const APPLICATION_DRAFT_STORAGE_KEY = "apprenticeship_application_draft"; +// Lives under the `forgeng.` prefix so sign-out clears it (see lib/session.ts). +export const APPLICATION_DRAFT_STORAGE_KEY = "forgeng.applicationDraft"; // Each wizard step has its own URL segment, e.g. /apply/background. The order // of this array is the order steps are shown in. diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts index 4c567b5..81b43a8 100644 --- a/frontend/src/lib/session.ts +++ b/frontend/src/lib/session.ts @@ -2,6 +2,30 @@ import type { UserProfile } from "@types"; const SESSION_KEY = "forgeng.session"; +/** + * Every `forgeng.`-prefixed localStorage key is treated as user-scoped and + * removed on sign-out (session, application drafts, …). Keep device-scoped + * preferences (e.g. theme) outside this prefix. + */ +const USER_SCOPED_KEY_PREFIX = "forgeng."; +/** Application drafts written before they moved under the prefix above. */ +const LEGACY_DRAFT_KEY_PREFIX = "apprenticeship_application_draft"; + +const removeUserScopedKeys = (): void => { + const keys: string[] = []; + for (let i = 0; i < window.localStorage.length; i += 1) { + const key = window.localStorage.key(i); + if ( + key && + (key.startsWith(USER_SCOPED_KEY_PREFIX) || + key.startsWith(LEGACY_DRAFT_KEY_PREFIX)) + ) { + keys.push(key); + } + } + keys.forEach((key) => window.localStorage.removeItem(key)); +}; + /** Cached snapshot so useSyncExternalStore getSnapshot stays referentially stable. */ let cachedRaw: string | null | undefined; let cachedUser: UserProfile | null = null; @@ -30,8 +54,7 @@ export const readSession = (): UserProfile | null => { export const writeSession = (user: UserProfile | null): void => { if (typeof window === "undefined") return; if (user == null) { - window.localStorage.removeItem(SESSION_KEY); - window.localStorage.removeItem("forgeng.activeUserId"); + removeUserScopedKeys(); cachedRaw = null; cachedUser = null; } else { From 64d1acf0d9e028e83949a126c19e912ff9605774 Mon Sep 17 00:00:00 2001 From: engdotme Date: Wed, 10 Jun 2026 21:23:22 -0400 Subject: [PATCH 15/31] chore: remove unused ProfileSheet component --- .../users/components/profile-sheet.tsx | 150 ------------------ 1 file changed, 150 deletions(-) delete mode 100644 frontend/src/features/users/components/profile-sheet.tsx diff --git a/frontend/src/features/users/components/profile-sheet.tsx b/frontend/src/features/users/components/profile-sheet.tsx deleted file mode 100644 index a32a7ae..0000000 --- a/frontend/src/features/users/components/profile-sheet.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client"; - -import { format } from "date-fns"; -import { BadgeCheck, Mail, MapPin, Monitor } from "lucide-react"; - -import { - DetailSheet, - ExternalLinkField, - LoadingState, - ProseBlock, - SectionTitle, -} from "@components/common"; -import { Badge } from "@components/ui/badge"; -import { COHORT_STATUS_VARIANT } from "@constants/cohorts"; -import { resolveAssetUrl } from "@lib/config"; -import type { AdminUserDetail } from "@types"; - -import { useUserEnrollments } from "../hooks"; - -export type ProfileSheetProps = { - user: AdminUserDetail; - open: boolean; - onClose: () => void; -}; - -export const ProfileSheet = ({ user, open, onClose }: ProfileSheetProps) => { - // Only fetch while the sheet is open; null short-circuits the request. - const { data: enrollments = [], isLoading } = useUserEnrollments( - open ? user.id : null, - ); - - const displayName = user.name ?? user.email; - - return ( - - - {user.email} - - } - > -
- {user.avatarUrl ? ( - // eslint-disable-next-line @next/next/no-img-element -- user avatar served by the API, not a static asset - {displayName} - ) : ( -
- {displayName[0]?.toUpperCase() ?? "?"} -
- )} -
- - {user.role} - - {user.emailVerified && ( - - - Verified - - )} -
-
- -
- Joined -

- {format(new Date(user.createdAt), "MMMM d, yyyy")} -

-
- - {(user.registrationIp || user.registrationCity || user.registrationCountry) && ( -
- IP -
- {user.registrationIp && ( -

- - {user.registrationIp} -

- )} - {(user.registrationCity || user.registrationCountry) && ( -

- - {[user.registrationCity, user.registrationCountry] - .filter(Boolean) - .join(", ")} -

- )} -
-
- )} - -
- Bio - {user.bio ? ( - {user.bio} - ) : ( -

No bio provided.

- )} -
- - {user.githubUrl && ( - - )} - -
- Enrollments - {isLoading ? ( - - ) : enrollments.length === 0 ? ( -

- Not enrolled in any cohort. -

- ) : ( -
- {enrollments.map((e) => ( -
-
-

- {e.cohort.name} -

-

- Enrolled {format(new Date(e.enrolledAt), "MMM d, yyyy")} -

-
- - {e.cohort.status} - -
- ))} -
- )} -
-
- ); -}; From cd930b4949106d1dfcb8bab8c88b7c10cc312170 Mon Sep 17 00:00:00 2001 From: engdotme Date: Wed, 10 Jun 2026 22:10:09 -0400 Subject: [PATCH 16/31] fix: resolve type errors in list-page-layout and applications page --- frontend/src/app/admin/applications/page.tsx | 8 ++++++-- .../src/components/shared/list-page-layout.tsx | 17 +++++------------ 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/admin/applications/page.tsx b/frontend/src/app/admin/applications/page.tsx index 3d6b2df..0ebafa8 100644 --- a/frontend/src/app/admin/applications/page.tsx +++ b/frontend/src/app/admin/applications/page.tsx @@ -25,8 +25,12 @@ const Page = () => { useData={useApplications} filter={filter} filterComponent={} - listComponent={(props) => } - onSelectItem={(app) => router.push(`/admin/applications/${app.id}`)} + listComponent={({ items }) => ( + router.push(`/admin/applications/${app.id}`)} + /> + )} emptyMessage="No applications in this category." loadingMessage="Loading applications…" /> diff --git a/frontend/src/components/shared/list-page-layout.tsx b/frontend/src/components/shared/list-page-layout.tsx index 9cbaac5..4de89b6 100644 --- a/frontend/src/components/shared/list-page-layout.tsx +++ b/frontend/src/components/shared/list-page-layout.tsx @@ -13,7 +13,7 @@ import { SelectValue, } from "@components/ui/select"; import { PAGE_SIZE_OPTIONS } from "@constants/shared/pagination"; -import { EmptyState, PageContainer, PageHeader } from "./index"; +import { EmptyState, PageContainer, PageHeader, type PageMaxWidth } from "./index"; export interface PaginatedData { items: T[]; @@ -47,14 +47,8 @@ export interface ListPageLayoutProps< filter: TFilter; - /** List component to render items */ - listComponent: React.ComponentType<{ - items: T[]; - onSelect?: (item: T) => void; - }>; - - /** Optional handler when an item is selected */ - onSelectItem?: (item: T) => void; + /** List component to render items (handle selection inside it) */ + listComponent: React.ComponentType<{ items: T[] }>; /** Empty state message */ emptyMessage?: string; @@ -63,7 +57,7 @@ export interface ListPageLayoutProps< loadingMessage?: string; /** Max container width (default: "5xl") */ - maxWidth?: string; + maxWidth?: PageMaxWidth; } export const ListPageLayout = < @@ -76,7 +70,6 @@ export const ListPageLayout = < filterComponent, filter, listComponent: ListComponent, - onSelectItem, emptyMessage = "No items found.", loadingMessage = "Loading…", maxWidth = "5xl", @@ -112,7 +105,7 @@ export const ListPageLayout = < ) : items.length === 0 ? ( ) : ( - + )} {total > 0 && ( From 0989a1704f0eacf8b7beb8f6ba48931209402699 Mon Sep 17 00:00:00 2001 From: engdotme Date: Thu, 11 Jun 2026 00:18:28 -0400 Subject: [PATCH 17/31] refactor: prefix feature component exports with their feature name --- frontend/src/app/admin/applications/page.tsx | 8 +- frontend/src/app/admin/cohorts/page.tsx | 6 +- frontend/src/app/admin/reviews/page.tsx | 4 +- frontend/src/app/admin/tasks/page.tsx | 6 +- frontend/src/app/admin/users/page.tsx | 4 +- frontend/src/app/apply/status/page.tsx | 4 +- frontend/src/app/student/cohort/page.tsx | 4 +- frontend/src/app/student/submissions/page.tsx | 8 +- frontend/src/app/student/tasks/[id]/page.tsx | 10 +- frontend/src/app/student/tasks/page.tsx | 8 +- .../components/application-detail-page.tsx | 4 +- .../applications/components/detail-dialog.tsx | 190 ------------------ .../features/applications/components/list.tsx | 8 +- .../features/applications/components/row.tsx | 8 +- .../applications/components/status-badge.tsx | 8 +- .../applications/components/status-tabs.tsx | 4 +- frontend/src/features/applications/index.ts | 18 +- .../features/cohorts/components/detail.tsx | 4 +- .../cohorts/components/form-dialog.tsx | 12 +- .../src/features/cohorts/components/row.tsx | 4 +- frontend/src/features/cohorts/index.ts | 8 +- .../submissions/components/review-sheet.tsx | 10 +- .../submissions/components/status-badge.tsx | 8 +- .../components/student/detail-sheet.tsx | 16 +- frontend/src/features/submissions/index.ts | 8 +- .../features/tasks/components/form-dialog.tsx | 12 +- .../src/features/tasks/components/row.tsx | 4 +- .../tasks/components/submit-dialog.tsx | 6 +- frontend/src/features/tasks/index.ts | 12 +- .../src/features/users/components/row.tsx | 4 +- frontend/src/features/users/index.ts | 4 +- 31 files changed, 111 insertions(+), 303 deletions(-) delete mode 100644 frontend/src/features/applications/components/detail-dialog.tsx diff --git a/frontend/src/app/admin/applications/page.tsx b/frontend/src/app/admin/applications/page.tsx index 0ebafa8..7f97756 100644 --- a/frontend/src/app/admin/applications/page.tsx +++ b/frontend/src/app/admin/applications/page.tsx @@ -5,8 +5,8 @@ import { useRouter } from "next/navigation"; import { ListPageLayout } from "@components/shared"; import { - List, - StatusTabs, + ApplicationList, + ApplicationStatusTabs, type Application, type ApplicationStatusFilter, useApplications, @@ -24,9 +24,9 @@ const Page = () => { }} useData={useApplications} filter={filter} - filterComponent={} + filterComponent={} listComponent={({ items }) => ( - router.push(`/admin/applications/${app.id}`)} /> diff --git a/frontend/src/app/admin/cohorts/page.tsx b/frontend/src/app/admin/cohorts/page.tsx index 1004d90..27930f1 100644 --- a/frontend/src/app/admin/cohorts/page.tsx +++ b/frontend/src/app/admin/cohorts/page.tsx @@ -6,7 +6,7 @@ import { Plus } from "lucide-react"; import { Button } from "@components/ui/button"; import { LoadingState } from "@components/common"; import { EmptyState, PageContainer, PageHeader } from "@components/shared"; -import { Row, FormDialog, useCohorts } from "@features/cohorts"; +import { CohortRow, CohortFormDialog, useCohorts } from "@features/cohorts"; import type { Cohort } from "@types"; const Page = () => { @@ -38,7 +38,7 @@ const Page = () => { ) : (
{cohorts.map((cohort) => ( - { @@ -51,7 +51,7 @@ const Page = () => {
)} - {

- + diff --git a/frontend/src/app/admin/tasks/page.tsx b/frontend/src/app/admin/tasks/page.tsx index 0045c65..4d8626d 100644 --- a/frontend/src/app/admin/tasks/page.tsx +++ b/frontend/src/app/admin/tasks/page.tsx @@ -6,7 +6,7 @@ import { Plus } from "lucide-react"; import { Button } from "@components/ui/button"; import { LoadingState } from "@components/common"; import { PageContainer, PageHeader } from "@components/shared"; -import { FormDialog, Row, useTasks } from "@features/tasks"; +import { TaskFormDialog, TaskRow, useTasks } from "@features/tasks"; import type { Task } from "@types"; const Page = () => { @@ -34,7 +34,7 @@ const Page = () => {
{isLoading ? : null} {tasks.map((task) => ( - { @@ -46,7 +46,7 @@ const Page = () => { ))}
- { const [page, setPage] = useState(1); @@ -48,7 +48,7 @@ const Page = () => { ) : (
{users.map((user) => ( - + ))}
)} diff --git a/frontend/src/app/apply/status/page.tsx b/frontend/src/app/apply/status/page.tsx index c4814c8..50afd3c 100644 --- a/frontend/src/app/apply/status/page.tsx +++ b/frontend/src/app/apply/status/page.tsx @@ -15,7 +15,7 @@ import { } from "@components/ui/card"; import { Skeleton } from "@components/ui/skeleton"; import { useCurrentUser } from "@contexts"; -import { getMyApplication, StatusBadge } from "@features/applications"; +import { getMyApplication, ApplicationStatusBadge } from "@features/applications"; import { useAsyncResource } from "@hooks/use-async-resource"; import type { ApplicationStatus } from "@types"; @@ -84,7 +84,7 @@ const StatusInner = () => {
{copy.title} - +
{copy.body}
diff --git a/frontend/src/app/student/cohort/page.tsx b/frontend/src/app/student/cohort/page.tsx index d1d3190..22a39e7 100644 --- a/frontend/src/app/student/cohort/page.tsx +++ b/frontend/src/app/student/cohort/page.tsx @@ -11,7 +11,7 @@ import { Card, CardContent } from "@components/ui/card"; import { Progress } from "@components/ui/progress"; import { EmptyState, PageContainer, PageHeader } from "@components/shared"; import { useSelectedCohort } from "@contexts"; -import { StatusBadge } from "@features/submissions"; +import { SubmissionStatusBadge } from "@features/submissions"; import { useSubmissions } from "@features/submissions"; import { CohortSwitcher, useStudentDashboard } from "@features/dashboard"; import { useTasks } from "@features/tasks"; @@ -156,7 +156,7 @@ const Page = () => {

{submission ? ( - + ) : ( Not started diff --git a/frontend/src/app/student/submissions/page.tsx b/frontend/src/app/student/submissions/page.tsx index 0e6b561..9c73b99 100644 --- a/frontend/src/app/student/submissions/page.tsx +++ b/frontend/src/app/student/submissions/page.tsx @@ -8,8 +8,8 @@ import { ClickableCard, LoadingState } from "@components/common"; import { Button } from "@components/ui/button"; import { EmptyState, PageContainer, PageHeader } from "@components/shared"; import { - DetailSheet, - StatusBadge, + SubmissionDetailSheet, + SubmissionStatusBadge, useSubmissions, } from "@features/submissions"; @@ -53,7 +53,7 @@ const Page = () => { {sub.feedbackCount} )} - + @@ -64,7 +64,7 @@ const Page = () => { )} {selected && ( - setSelectedId(null)} diff --git a/frontend/src/app/student/tasks/[id]/page.tsx b/frontend/src/app/student/tasks/[id]/page.tsx index 254d752..8b5cd87 100644 --- a/frontend/src/app/student/tasks/[id]/page.tsx +++ b/frontend/src/app/student/tasks/[id]/page.tsx @@ -12,8 +12,8 @@ import { Button } from "@components/ui/button"; import { Card } from "@components/ui/card"; import { EmptyState, PageContainer, PageHeader } from "@components/shared"; import { TASK_TYPE_ICON } from "@constants/tasks"; -import { DetailSheet, StatusBadge, useSubmissions } from "@features/submissions"; -import { SubmitDialog, useTask } from "@features/tasks"; +import { SubmissionDetailSheet, SubmissionStatusBadge, useSubmissions } from "@features/submissions"; +import { TaskSubmitDialog, useTask } from "@features/tasks"; const Page = () => { const params = useParams<{ id: string }>(); @@ -114,7 +114,7 @@ const Page = () => { {submission ? (
- + Submitted{" "} {format(new Date(submission.createdAt), "MMM d, yyyy")} @@ -139,7 +139,7 @@ const Page = () => { )}
- { /> {submission && ( - setDetailOpen(false)} diff --git a/frontend/src/app/student/tasks/page.tsx b/frontend/src/app/student/tasks/page.tsx index 0a7f293..cd99b28 100644 --- a/frontend/src/app/student/tasks/page.tsx +++ b/frontend/src/app/student/tasks/page.tsx @@ -19,8 +19,8 @@ import { import { Tabs, TabsList, TabsTrigger } from "@components/ui/tabs"; import { LoadingState } from "@components/common"; import { EmptyState, PageContainer, PageHeader } from "@components/shared"; -import { StatusBadge } from "@features/submissions"; -import { SubmitDialog } from "@features/tasks"; +import { SubmissionStatusBadge } from "@features/submissions"; +import { TaskSubmitDialog } from "@features/tasks"; import { TASK_PROGRESS_FILTER_TABS, TASK_SORT_OPTIONS, @@ -247,7 +247,7 @@ const Page = () => {
{submission ? ( - + ) : (

{application.email} Β· Applied{" "} diff --git a/frontend/src/features/applications/components/detail-dialog.tsx b/frontend/src/features/applications/components/detail-dialog.tsx deleted file mode 100644 index 161ec73..0000000 --- a/frontend/src/features/applications/components/detail-dialog.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { format } from "date-fns"; -import { toast } from "sonner"; - -import { - DetailField, - DetailGrid, - FormBody, - FormDialog, - FormField, - ProseBlock, - SectionTitle, -} from "@components/common"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@components/ui/select"; -import { Textarea } from "@components/ui/textarea"; -import { APPLICATION_STATUS_OPTIONS } from "@constants/applications"; -import { useCohorts } from "@features/cohorts"; -import { isSafeHref } from "@utils"; -import { useUpdateApplicationStatus } from "../hooks"; -import type { Application, ApplicationStatus } from "@types"; - -export type DetailDialogProps = { - application: Application; - open: boolean; - onOpenChange: (open: boolean) => void; -}; - -export const DetailDialog = ({ - application, - open, - onOpenChange, -}: DetailDialogProps) => { - const { data: cohorts = [] } = useCohorts(); - const { update, isPending } = useUpdateApplicationStatus(); - - const [status, setStatus] = useState(application.status); - const [reviewerNote, setReviewerNote] = useState( - application.reviewerNote ?? "", - ); - const [cohortId, setCohortId] = useState( - application.cohortId?.toString() ?? "", - ); - - const handleSave = async () => { - try { - await update(application.id, { - status, - reviewerNote: reviewerNote || null, - cohortId: - status === "accepted" && cohortId - ? Number.parseInt(cohortId, 10) - : null, - }); - toast.success("Application updated"); - onOpenChange(false); - } catch { - toast.error("Failed to update application"); - } - }; - - return ( - - - - - - - - {application.motivation && ( -

- Motivation - {application.motivation} -
- )} - - {application.background && ( -
- Background - {application.background} -
- )} - - {application.experience && ( -
- Experience - {application.experience} -
- )} - - {(application.linkedin || - application.github || - application.twitter || - application.facebook || - application.portfolio) && ( -
- )} - - - - - - {status === "accepted" && cohorts.length > 0 && ( - - - - )} - - -