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) && (
(
);
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 }) => (
),
)}
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[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 = () => {
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) && (
-
-
Social Profiles
-
- {(
- [
- { key: "linkedin", label: "LinkedIn" },
- { key: "github", label: "GitHub" },
- { key: "twitter", label: "Twitter" },
- { key: "facebook", label: "Facebook" },
- { key: "portfolio", label: "Portfolio" },
- ] as const
- )
- .filter(({ key }) => isSafeHref(application[key] ?? ""))
- .map(({ key, label }) => (
-
- {label}
-
- ))}
-
-
- )}
-
-
-
-
-
- {status === "accepted" && cohorts.length > 0 && (
-
-
-
- )}
-
-
-
-
-
- );
-};
diff --git a/frontend/src/features/applications/components/list.tsx b/frontend/src/features/applications/components/list.tsx
index 0beb993..c52353b 100644
--- a/frontend/src/features/applications/components/list.tsx
+++ b/frontend/src/features/applications/components/list.tsx
@@ -1,16 +1,16 @@
import type { Application } from "@types";
-import { Row } from "./row";
+import { ApplicationRow } from "./row";
-export type ListProps = {
+export type ApplicationListProps = {
applications: Application[];
onSelect: (application: Application) => void;
};
-export const List = ({ applications, onSelect }: ListProps) => (
+export const ApplicationList = ({ applications, onSelect }: ApplicationListProps) => (
{applications.map((app) => (
-
onSelect(app)}
diff --git a/frontend/src/features/applications/components/row.tsx b/frontend/src/features/applications/components/row.tsx
index 326e515..5ffcfbb 100644
--- a/frontend/src/features/applications/components/row.tsx
+++ b/frontend/src/features/applications/components/row.tsx
@@ -6,14 +6,14 @@ import { Card } from "@components/ui/card";
import type { Application } from "@types";
import { isApplicationComplete } from "@utils/user";
-import { StatusBadge } from "./status-badge";
+import { ApplicationStatusBadge } from "./status-badge";
-export type RowProps = {
+export type ApplicationRowProps = {
application: Application;
onSelect: () => void;
};
-export const Row = ({ application, onSelect }: RowProps) => {
+export const ApplicationRow = ({ application, onSelect }: ApplicationRowProps) => {
const incomplete = !isApplicationComplete(application);
return (
@@ -38,7 +38,7 @@ export const Row = ({ application, onSelect }: RowProps) => {
{format(new Date(application.createdAt), "MMM d, yyyy")}
-
+
-
+
))}
diff --git a/frontend/src/features/cohorts/components/form-dialog.tsx b/frontend/src/features/cohorts/components/form-dialog.tsx
index b15dd2d..aa6c79c 100644
--- a/frontend/src/features/cohorts/components/form-dialog.tsx
+++ b/frontend/src/features/cohorts/components/form-dialog.tsx
@@ -5,7 +5,7 @@ import { toast } from "sonner";
import {
FormBody,
- FormDialog as BaseFormDialog,
+ FormDialog,
FormField,
FormGrid,
} from "@components/common";
@@ -24,19 +24,19 @@ import { ApiError } from "@lib/api-client";
import { createCohort, updateCohort } from "../api";
import type { Cohort, CohortStatus } from "@types";
-export type FormDialogProps = {
+export type CohortFormDialogProps = {
cohort?: Cohort;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved?: () => void;
};
-export const FormDialog = ({
+export const CohortFormDialog = ({
cohort,
open,
onOpenChange,
onSaved,
-}: FormDialogProps) => {
+}: CohortFormDialogProps) => {
const isEdit = !!cohort;
const [name, setName] = useState(cohort?.name ?? "");
const [description, setDescription] = useState(cohort?.description ?? "");
@@ -83,7 +83,7 @@ export const FormDialog = ({
};
return (
-
-
+
);
};
diff --git a/frontend/src/features/cohorts/components/row.tsx b/frontend/src/features/cohorts/components/row.tsx
index 5ad7989..752cf0d 100644
--- a/frontend/src/features/cohorts/components/row.tsx
+++ b/frontend/src/features/cohorts/components/row.tsx
@@ -15,13 +15,13 @@ import { ApiError } from "@lib/api-client";
import { deleteCohort } from "../api";
import type { Cohort } from "@types";
-export type RowProps = {
+export type CohortRowProps = {
cohort: Cohort;
onEdit: (cohort: Cohort) => void;
onDeleted?: () => void;
};
-export const Row = ({ cohort, onEdit, onDeleted }: RowProps) => {
+export const CohortRow = ({ cohort, onEdit, onDeleted }: CohortRowProps) => {
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false);
const detailHref = `/admin/cohorts/${cohort.id}`;
diff --git a/frontend/src/features/cohorts/index.ts b/frontend/src/features/cohorts/index.ts
index 9136b73..05b2f88 100644
--- a/frontend/src/features/cohorts/index.ts
+++ b/frontend/src/features/cohorts/index.ts
@@ -12,11 +12,11 @@ export {
} from "./api";
export { useCohorts, useCohort, useEnrollments } from "./hooks";
-export { FormDialog } from "./components/form-dialog";
-export type { FormDialogProps } from "./components/form-dialog";
+export { CohortFormDialog } from "./components/form-dialog";
+export type { CohortFormDialogProps } from "./components/form-dialog";
export { Enrollments } from "./components/enrollments";
export type { EnrollmentsProps } from "./components/enrollments";
-export { Row } from "./components/row";
-export type { RowProps } from "./components/row";
+export { CohortRow } from "./components/row";
+export type { CohortRowProps } from "./components/row";
export { CohortDetail } from "./components/detail";
export type { CohortDetailProps } from "./components/detail";
diff --git a/frontend/src/features/submissions/components/review-sheet.tsx b/frontend/src/features/submissions/components/review-sheet.tsx
index b9e94a3..fb13ebc 100644
--- a/frontend/src/features/submissions/components/review-sheet.tsx
+++ b/frontend/src/features/submissions/components/review-sheet.tsx
@@ -5,7 +5,7 @@ import { format } from "date-fns";
import { toast } from "sonner";
import {
- DetailSheet as BaseDetailSheet,
+ DetailSheet,
ExternalLinkField,
FeedbackCard,
FormField,
@@ -21,7 +21,7 @@ import type { FeedbackVerdict, Submission } from "@types";
import { createFeedback } from "../api";
import { useSubmissionFeedback } from "../hooks";
-import { StatusBadge } from "./status-badge";
+import { SubmissionStatusBadge } from "./status-badge";
export type ReviewSheetProps = {
submission: Submission;
@@ -66,7 +66,7 @@ export const ReviewSheet = ({
};
return (
-
{format(new Date(submission.createdAt), "MMM d, yyyy")}
-
+
}
>
@@ -135,6 +135,6 @@ export const ReviewSheet = ({
)}
-
+
);
};
diff --git a/frontend/src/features/submissions/components/status-badge.tsx b/frontend/src/features/submissions/components/status-badge.tsx
index c4324f8..42dd8fa 100644
--- a/frontend/src/features/submissions/components/status-badge.tsx
+++ b/frontend/src/features/submissions/components/status-badge.tsx
@@ -1,15 +1,15 @@
import { AlertCircle, CheckCircle2 } from "lucide-react";
-import { StatusBadge as BaseStatusBadge } from "@components/common";
+import { StatusBadge } from "@components/common";
import { SUBMISSION_STATUS_VARIANT } from "@constants/submissions";
import type { SubmissionStatus } from "@types";
-export type StatusBadgeProps = {
+export type SubmissionStatusBadgeProps = {
status: SubmissionStatus;
showIcon?: boolean;
};
-export const StatusBadge = ({ status, showIcon = true }: StatusBadgeProps) => {
+export const SubmissionStatusBadge = ({ status, showIcon = true }: SubmissionStatusBadgeProps) => {
const leadingIcon =
showIcon && status === "approved" ? (
@@ -18,7 +18,7 @@ export const StatusBadge = ({ status, showIcon = true }: StatusBadgeProps) => {
) : undefined;
return (
- void;
@@ -32,12 +32,12 @@ export type DetailSheetProps = {
onResubmitted?: () => void;
};
-export const DetailSheet = ({
+export const SubmissionDetailSheet = ({
submission,
open,
onClose,
onResubmitted,
-}: DetailSheetProps) => {
+}: SubmissionDetailSheetProps) => {
const { data: feedback = [] } = useSubmissionFeedback(
open ? submission.id : null,
);
@@ -68,13 +68,13 @@ export const DetailSheet = ({
};
return (
-
-
+
Submitted {format(new Date(submission.createdAt), "MMM d, yyyy")}
@@ -148,6 +148,6 @@ export const DetailSheet = ({
>
)}
-
+
);
};
diff --git a/frontend/src/features/submissions/index.ts b/frontend/src/features/submissions/index.ts
index 12703ae..e32a7f5 100644
--- a/frontend/src/features/submissions/index.ts
+++ b/frontend/src/features/submissions/index.ts
@@ -22,10 +22,10 @@ export {
} from "./api";
export type { SubmissionStatusFilter, UseSubmissionsOptions } from "./hooks";
export { useSubmissions, useSubmissionFeedback } from "./hooks";
-export { StatusBadge } from "./components/status-badge";
-export type { StatusBadgeProps } from "./components/status-badge";
+export { SubmissionStatusBadge } from "./components/status-badge";
+export type { SubmissionStatusBadgeProps } from "./components/status-badge";
-export { DetailSheet } from "./components/student/detail-sheet";
-export type { DetailSheetProps } from "./components/student/detail-sheet";
+export { SubmissionDetailSheet } from "./components/student/detail-sheet";
+export type { SubmissionDetailSheetProps } from "./components/student/detail-sheet";
export { ReviewSheet } from "./components/review-sheet";
export type { ReviewSheetProps } from "./components/review-sheet";
diff --git a/frontend/src/features/tasks/components/form-dialog.tsx b/frontend/src/features/tasks/components/form-dialog.tsx
index 9b7c15d..bef34e8 100644
--- a/frontend/src/features/tasks/components/form-dialog.tsx
+++ b/frontend/src/features/tasks/components/form-dialog.tsx
@@ -5,7 +5,7 @@ import { toast } from "sonner";
import {
FormBody,
- FormDialog as BaseFormDialog,
+ FormDialog,
FormField,
FormGrid,
} from "@components/common";
@@ -25,19 +25,19 @@ import { ApiError } from "@lib/api-client";
import { createTask, updateTask } from "../api";
import type { Task, TaskStatus, TaskType } from "@types";
-export type FormDialogProps = {
+export type TaskFormDialogProps = {
task?: Task;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved?: () => void;
};
-export const FormDialog = ({
+export const TaskFormDialog = ({
task,
open,
onOpenChange,
onSaved,
-}: FormDialogProps) => {
+}: TaskFormDialogProps) => {
const { data: cohorts = [] } = useCohorts();
const isEdit = !!task;
const [title, setTitle] = useState(task?.title ?? "");
@@ -83,7 +83,7 @@ export const FormDialog = ({
};
return (
-
-
+
);
};
diff --git a/frontend/src/features/tasks/components/row.tsx b/frontend/src/features/tasks/components/row.tsx
index dfcf066..fb7f2e4 100644
--- a/frontend/src/features/tasks/components/row.tsx
+++ b/frontend/src/features/tasks/components/row.tsx
@@ -13,13 +13,13 @@ import { ApiError } from "@lib/api-client";
import { deleteTask } from "../api";
import type { Task } from "@types";
-export type RowProps = {
+export type TaskRowProps = {
task: Task;
onEdit: (task: Task) => void;
onDeleted?: () => void;
};
-export const Row = ({ task, onEdit, onDeleted }: RowProps) => {
+export const TaskRow = ({ task, onEdit, onDeleted }: TaskRowProps) => {
const Icon = TASK_TYPE_ICON[task.type] ?? Code2;
const [isDeleting, setIsDeleting] = useState(false);
diff --git a/frontend/src/features/tasks/components/submit-dialog.tsx b/frontend/src/features/tasks/components/submit-dialog.tsx
index 54b8651..ef7dc10 100644
--- a/frontend/src/features/tasks/components/submit-dialog.tsx
+++ b/frontend/src/features/tasks/components/submit-dialog.tsx
@@ -10,7 +10,7 @@ import { createSubmission } from "@features/submissions";
import { ApiError } from "@lib/api-client";
import type { Task } from "@types";
-export type SubmitDialogProps = {
+export type TaskSubmitDialogProps = {
task: Task;
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -18,12 +18,12 @@ export type SubmitDialogProps = {
onSubmitted?: () => void;
};
-export const SubmitDialog = ({
+export const TaskSubmitDialog = ({
task,
open,
onOpenChange,
onSubmitted,
-}: SubmitDialogProps) => {
+}: TaskSubmitDialogProps) => {
const [content, setContent] = useState("");
const [repoUrl, setRepoUrl] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
diff --git a/frontend/src/features/tasks/index.ts b/frontend/src/features/tasks/index.ts
index 16ff7d9..066e9f8 100644
--- a/frontend/src/features/tasks/index.ts
+++ b/frontend/src/features/tasks/index.ts
@@ -14,9 +14,9 @@ export {
} from "./api";
export { useTasks, useTask } from "./hooks";
-export { FormDialog } from "./components/form-dialog";
-export type { FormDialogProps } from "./components/form-dialog";
-export { Row } from "./components/row";
-export type { RowProps } from "./components/row";
-export { SubmitDialog } from "./components/submit-dialog";
-export type { SubmitDialogProps } from "./components/submit-dialog";
+export { TaskFormDialog } from "./components/form-dialog";
+export type { TaskFormDialogProps } from "./components/form-dialog";
+export { TaskRow } from "./components/row";
+export type { TaskRowProps } from "./components/row";
+export { TaskSubmitDialog } from "./components/submit-dialog";
+export type { TaskSubmitDialogProps } from "./components/submit-dialog";
diff --git a/frontend/src/features/users/components/row.tsx b/frontend/src/features/users/components/row.tsx
index 2d22ecd..a25f8d2 100644
--- a/frontend/src/features/users/components/row.tsx
+++ b/frontend/src/features/users/components/row.tsx
@@ -6,9 +6,9 @@ import Link from "next/link";
import { Card } from "@components/ui/card";
import type { UserProfile } from "@types";
-export type RowProps = { user: UserProfile };
+export type UserRowProps = { user: UserProfile };
-export const Row = ({ user }: RowProps) => {
+export const UserRow = ({ user }: UserRowProps) => {
return (
diff --git a/frontend/src/features/users/index.ts b/frontend/src/features/users/index.ts
index f303800..99e421d 100644
--- a/frontend/src/features/users/index.ts
+++ b/frontend/src/features/users/index.ts
@@ -10,5 +10,5 @@ export type { UserRoleFilter } from "@types";
export { USER_ROLE_FILTER_TABS, USER_ROLE_OPTIONS } from "@constants/users";
export { useUsers, useUserEnrollments, useRecordPayment, useNotifyWalletMissing } from "./hooks";
-export { Row } from "./components/row";
-export type { RowProps } from "./components/row";
+export { UserRow } from "./components/row";
+export type { UserRowProps } from "./components/row";
From 48bb1637f65d031464795ceb7d446680e413aa52 Mon Sep 17 00:00:00 2001
From: engdotme
Date: Thu, 11 Jun 2026 13:50:20 -0400
Subject: [PATCH 18/31] feat: block register/login from outside specific
country or via VPN
---
backend/.env.example | 5 ++
backend/src/config/config.types.ts | 3 +
backend/src/config/configuration.ts | 3 +
backend/src/config/env.validation.ts | 4 ++
backend/src/modules/auth/auth.module.ts | 4 ++
backend/src/modules/auth/auth.service.ts | 22 +++++++
.../auth/services/ip-reputation.service.ts | 60 +++++++++++++++++++
.../services/region-restriction.service.ts | 26 ++++++++
8 files changed, 127 insertions(+)
create mode 100644 backend/src/modules/auth/services/ip-reputation.service.ts
create mode 100644 backend/src/modules/auth/services/region-restriction.service.ts
diff --git a/backend/.env.example b/backend/.env.example
index 4f1bdd3..3c38612 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -67,3 +67,8 @@ SMTP_PASS=
# "true" or "1" to use TLS.
SMTP_SECURE=false
EMAIL_FROM="no-reply@forgeng.local"
+
+# --- IP reputation / region restriction ---
+# IPQualityScore API key, used to detect VPNs/proxies and the visitor's country
+# on register/login. Leave unset to skip the check entirely.
+IPQUALITYSCORE_API_KEY=
diff --git a/backend/src/config/config.types.ts b/backend/src/config/config.types.ts
index 0fb10f8..779422c 100644
--- a/backend/src/config/config.types.ts
+++ b/backend/src/config/config.types.ts
@@ -29,6 +29,9 @@ export interface AppConfiguration {
};
smtp: SmtpConfig | null;
emailFrom: string;
+ ipQualityScore: {
+ apiKey: string | undefined;
+ };
}
export interface OAuthProviderConfig {
diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts
index 119641a..c85099a 100644
--- a/backend/src/config/configuration.ts
+++ b/backend/src/config/configuration.ts
@@ -128,5 +128,8 @@ export default (): AppConfiguration => {
},
smtp: parseSmtp(),
emailFrom: process.env.EMAIL_FROM ?? DEFAULT_EMAIL_FROM,
+ ipQualityScore: {
+ apiKey: process.env.IPQUALITYSCORE_API_KEY,
+ },
};
};
diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts
index bbc5e7a..c58ee37 100644
--- a/backend/src/config/env.validation.ts
+++ b/backend/src/config/env.validation.ts
@@ -142,6 +142,10 @@ class EnvironmentVariables {
@IsOptional()
@IsString()
EMAIL_FROM?: string;
+
+ @IsOptional()
+ @IsString()
+ IPQUALITYSCORE_API_KEY?: string;
}
/** Validates `process.env` at startup; fails fast on missing required vars. */
diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts
index b3be605..b31d7eb 100644
--- a/backend/src/modules/auth/auth.module.ts
+++ b/backend/src/modules/auth/auth.module.ts
@@ -8,7 +8,9 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { EmailService } from './services/email.service';
import { GeoService } from './services/geo.service';
+import { IpReputationService } from './services/ip-reputation.service';
import { PasswordService } from './services/password.service';
+import { RegionRestrictionService } from './services/region-restriction.service';
import { TokenService } from './services/token.service';
import { VerificationService } from './services/verification.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@@ -31,7 +33,9 @@ import { GitHubStrategy } from './strategies/github.strategy';
providers: [
AuthService,
GeoService,
+ IpReputationService,
PasswordService,
+ RegionRestrictionService,
TokenService,
VerificationService,
EmailService,
diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts
index e996bb5..03a343f 100644
--- a/backend/src/modules/auth/auth.service.ts
+++ b/backend/src/modules/auth/auth.service.ts
@@ -16,6 +16,7 @@ import type { LoginDto } from './dto/login.dto';
import { EmailService } from './services/email.service';
import { GeoService } from './services/geo.service';
import { PasswordService } from './services/password.service';
+import { RegionRestrictionService } from './services/region-restriction.service';
import { TokenService } from './services/token.service';
import { VerificationService } from './services/verification.service';
import type { IssuedTokens } from './types/jwt-payload.types';
@@ -40,6 +41,7 @@ export class AuthService {
private readonly verification: VerificationService,
private readonly email: EmailService,
private readonly geo: GeoService,
+ private readonly regionRestriction: RegionRestrictionService,
private readonly config: ConfigService,
) {}
@@ -47,6 +49,8 @@ export class AuthService {
dto: RegisterDto,
ctx: RequestContext,
): Promise<{ user: UserDto }> {
+ await this.assertRegionAllowed(ctx.ip);
+
const email = dto.email.toLowerCase();
const existing = await this.prisma.user.findUnique({ where: { email } });
if (existing) {
@@ -72,6 +76,8 @@ export class AuthService {
}
async login(dto: LoginDto, ctx: RequestContext): Promise {
+ await this.assertRegionAllowed(ctx.ip);
+
const email = dto.email.toLowerCase();
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !user.passwordHash) {
@@ -251,4 +257,20 @@ export class AuthService {
const resetUrl = `${base}?token=${encodeURIComponent(rawToken)}`;
await this.email.sendPasswordResetEmail(user.email, resetUrl);
}
+
+ private async assertRegionAllowed(ip: string | undefined): Promise {
+ const restriction = await this.regionRestriction.check(ip);
+ if (restriction === 'vpn') {
+ throw new ForbiddenException({
+ code: 'VPN_DETECTED',
+ message: 'Please disable your VPN or proxy and try again.',
+ });
+ }
+ if (restriction === 'region') {
+ throw new ForbiddenException({
+ code: 'REGION_BLOCKED',
+ message: 'This service is only available in the United States and Canada.',
+ });
+ }
+ }
}
diff --git a/backend/src/modules/auth/services/ip-reputation.service.ts b/backend/src/modules/auth/services/ip-reputation.service.ts
new file mode 100644
index 0000000..efff144
--- /dev/null
+++ b/backend/src/modules/auth/services/ip-reputation.service.ts
@@ -0,0 +1,60 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+
+import type { AppConfiguration } from '@config';
+
+export interface IpReputationResult {
+ countryCode: string | null;
+ isVpn: boolean;
+}
+
+const REQUEST_TIMEOUT_MS = 3000;
+
+interface IpqsResponse {
+ success?: boolean;
+ country_code?: string;
+ vpn?: boolean;
+ proxy?: boolean;
+ tor?: boolean;
+}
+
+const EMPTY_RESULT: IpReputationResult = { countryCode: null, isVpn: false };
+
+@Injectable()
+export class IpReputationService {
+ private readonly logger = new Logger(IpReputationService.name);
+
+ constructor(private readonly config: ConfigService) {}
+
+ /**
+ * Looks up the country and VPN/proxy/Tor status for an IP via IPQualityScore.
+ * Returns an empty (non-blocking) result if no API key is configured, the IP
+ * is missing, or the lookup fails β callers should fail open in that case.
+ */
+ async lookup(rawIp: string | undefined): Promise {
+ const apiKey = this.config.get('ipQualityScore.apiKey', { infer: true });
+ if (!apiKey || !rawIp) return EMPTY_RESULT;
+
+ // Strip IPv4-mapped IPv6 prefix (::ffff:1.2.3.4 β 1.2.3.4).
+ const ip = rawIp.replace(/^::ffff:/, '');
+
+ try {
+ const url = `https://ipqualityscore.com/api/json/ip/${apiKey}/${encodeURIComponent(ip)}?strictness=1`;
+ const res = await fetch(url, {
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ if (!res.ok) return EMPTY_RESULT;
+
+ const data = (await res.json()) as IpqsResponse;
+ if (data.success === false) return EMPTY_RESULT;
+
+ return {
+ countryCode: data.country_code ?? null,
+ isVpn: Boolean(data.vpn || data.proxy || data.tor),
+ };
+ } catch (err) {
+ this.logger.warn(`IP reputation lookup failed: ${(err as Error).message}`);
+ return EMPTY_RESULT;
+ }
+ }
+}
diff --git a/backend/src/modules/auth/services/region-restriction.service.ts b/backend/src/modules/auth/services/region-restriction.service.ts
new file mode 100644
index 0000000..a91fc4c
--- /dev/null
+++ b/backend/src/modules/auth/services/region-restriction.service.ts
@@ -0,0 +1,26 @@
+import { Injectable } from '@nestjs/common';
+
+import { IpReputationService } from './ip-reputation.service';
+
+/** ISO 3166-1 alpha-2 codes the service is currently available in. */
+const ALLOWED_COUNTRY_CODES = new Set(['US', 'CA']);
+
+export type AccessRestriction = 'region' | 'vpn';
+
+@Injectable()
+export class RegionRestrictionService {
+ constructor(private readonly ipReputation: IpReputationService) {}
+
+ /**
+ * Returns the reason access should be blocked for this IP, or null if
+ * access is allowed. Fails open (returns null) when the country/VPN
+ * status can't be determined.
+ */
+ async check(ip: string | undefined): Promise {
+ const { countryCode, isVpn } = await this.ipReputation.lookup(ip);
+
+ if (isVpn) return 'vpn';
+ if (countryCode && !ALLOWED_COUNTRY_CODES.has(countryCode)) return 'region';
+ return null;
+ }
+}
From a796eab8d31590e48ce555e09bbe4433d58f3998 Mon Sep 17 00:00:00 2001
From: engdotme
Date: Thu, 11 Jun 2026 15:04:26 -0400
Subject: [PATCH 19/31] feat: redirect blocked sign-in/sign-up to /unavailable
---
backend/src/modules/auth/auth.service.ts | 3 ++-
.../modules/auth/services/ip-reputation.service.ts | 4 +++-
frontend/src/app/(auth)/sign-in/page.tsx | 7 ++++++-
frontend/src/app/(auth)/sign-up/page.tsx | 6 ++++++
frontend/src/utils/auth.ts | 13 +++++++++++++
5 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts
index 03a343f..32f5f7c 100644
--- a/backend/src/modules/auth/auth.service.ts
+++ b/backend/src/modules/auth/auth.service.ts
@@ -269,7 +269,8 @@ export class AuthService {
if (restriction === 'region') {
throw new ForbiddenException({
code: 'REGION_BLOCKED',
- message: 'This service is only available in the United States and Canada.',
+ message:
+ 'This service is only available in the United States and Canada.',
});
}
}
diff --git a/backend/src/modules/auth/services/ip-reputation.service.ts b/backend/src/modules/auth/services/ip-reputation.service.ts
index efff144..d50a85a 100644
--- a/backend/src/modules/auth/services/ip-reputation.service.ts
+++ b/backend/src/modules/auth/services/ip-reputation.service.ts
@@ -53,7 +53,9 @@ export class IpReputationService {
isVpn: Boolean(data.vpn || data.proxy || data.tor),
};
} catch (err) {
- this.logger.warn(`IP reputation lookup failed: ${(err as Error).message}`);
+ this.logger.warn(
+ `IP reputation lookup failed: ${(err as Error).message}`,
+ );
return EMPTY_RESULT;
}
}
diff --git a/frontend/src/app/(auth)/sign-in/page.tsx b/frontend/src/app/(auth)/sign-in/page.tsx
index d420eec..f77da9f 100644
--- a/frontend/src/app/(auth)/sign-in/page.tsx
+++ b/frontend/src/app/(auth)/sign-in/page.tsx
@@ -8,7 +8,7 @@ import { toast } from "sonner";
import { useCurrentUser } from "@contexts";
import { AuthCard } from "@features/auth";
import { ApiError } from "@lib/api-client";
-import { homeForRole } from "@utils/auth";
+import { accessRestrictionReason, homeForRole } from "@utils/auth";
const schema = z.object({
email: z.email("Enter a valid email."),
@@ -27,6 +27,11 @@ const Page = () => {
toast.success(`Signed in as ${user.name ?? user.email}.`);
router.push(homeForRole(user.role));
} catch (err) {
+ const restriction = accessRestrictionReason(err);
+ if (restriction) {
+ router.push(`/unavailable?reason=${restriction}`);
+ return;
+ }
if (err instanceof ApiError) {
if (err.status === 403) {
toast.error(
diff --git a/frontend/src/app/(auth)/sign-up/page.tsx b/frontend/src/app/(auth)/sign-up/page.tsx
index 8b385b7..2639196 100644
--- a/frontend/src/app/(auth)/sign-up/page.tsx
+++ b/frontend/src/app/(auth)/sign-up/page.tsx
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { useCurrentUser } from "@contexts";
import { AuthCard } from "@features/auth";
import { ApiError } from "@lib/api-client";
+import { accessRestrictionReason } from "@utils/auth";
const schema = z.object({
firstName: z.string().trim().min(1, "First name is required.").max(80),
@@ -39,6 +40,11 @@ const Page = () => {
`/sign-up/check-email?email=${encodeURIComponent(values.email)}`,
);
} catch (err) {
+ const restriction = accessRestrictionReason(err);
+ if (restriction) {
+ router.push(`/unavailable?reason=${restriction}`);
+ return;
+ }
if (err instanceof ApiError && err.status === 409) {
toast.error("That email is already registered. Try signing in.");
} else if (err instanceof ApiError) {
diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts
index e62b1c2..df49ea8 100644
--- a/frontend/src/utils/auth.ts
+++ b/frontend/src/utils/auth.ts
@@ -1,5 +1,18 @@
+import { ApiError } from "@lib/api-client";
import type { UserProfile } from "@types";
+const ACCESS_RESTRICTION_REASONS: Record = {
+ REGION_BLOCKED: "region",
+ VPN_DETECTED: "vpn",
+};
+
+/** Maps a region/VPN restriction error to its `/unavailable?reason=` value, or null. */
+export const accessRestrictionReason = (err: unknown): "region" | "vpn" | null => {
+ if (!(err instanceof ApiError) || err.status !== 403) return null;
+ const code = (err.body as { code?: string } | undefined)?.code;
+ return code ? (ACCESS_RESTRICTION_REASONS[code] ?? null) : null;
+};
+
/** Default landing page for a given role (used by the role guard + sign-in). */
export const homeForRole = (role: UserProfile["role"]): string => {
switch (role) {
From 83b30fba17d7fa26215819883f5528020fce2606 Mon Sep 17 00:00:00 2001
From: engdotme
Date: Thu, 11 Jun 2026 15:12:13 -0400
Subject: [PATCH 20/31] feat: redirect blocked sign-in/sign-up to /unavailable
---
frontend/src/app/(auth)/unavailable/page.tsx | 44 ++++++++++++++++++++
1 file changed, 44 insertions(+)
create mode 100644 frontend/src/app/(auth)/unavailable/page.tsx
diff --git a/frontend/src/app/(auth)/unavailable/page.tsx b/frontend/src/app/(auth)/unavailable/page.tsx
new file mode 100644
index 0000000..664bd78
--- /dev/null
+++ b/frontend/src/app/(auth)/unavailable/page.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import { Suspense } from "react";
+import { ShieldAlert } from "lucide-react";
+
+import { AuthCard } from "@features/auth";
+
+const COPY = {
+ vpn: {
+ title: "VPN or proxy detected",
+ description:
+ "Please disable your VPN, proxy, or Tor and refresh the page to continue.",
+ },
+ region: {
+ title: "Unavailable in your region",
+ description:
+ "This service is currently only available to users in the United States and Canada.",
+ },
+} as const;
+
+const UnavailableInner = () => {
+ const params = useSearchParams();
+ const reason = params.get("reason") === "vpn" ? "vpn" : "region";
+ const copy = COPY[reason];
+
+ return (
+
+
+
+ );
+};
+
+const Page = () => (
+
+
+
+);
+
+export default Page;
From 9d431633351c0be32f16e6a41d3effa0033da987 Mon Sep 17 00:00:00 2001
From: engdotme
Date: Thu, 11 Jun 2026 15:22:38 -0400
Subject: [PATCH 21/31] feat: apply region/VPN restriction to OAuth sign-in too
---
backend/src/modules/auth/auth.controller.ts | 23 ++++++++++++++++++++-
backend/src/modules/auth/auth.service.ts | 2 ++
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts
index 2e59333..46db359 100644
--- a/backend/src/modules/auth/auth.controller.ts
+++ b/backend/src/modules/auth/auth.controller.ts
@@ -2,6 +2,7 @@ import {
BadRequestException,
Body,
Controller,
+ ForbiddenException,
Get,
HttpCode,
HttpStatus,
@@ -193,7 +194,15 @@ export class AuthController {
infer: true,
});
res.redirect(target);
- } catch {
+ } catch (err) {
+ const restrictionReason = this.accessRestrictionReason(err);
+ if (restrictionReason) {
+ const frontendUrl = this.config.getOrThrow('frontendUrl', {
+ infer: true,
+ });
+ res.redirect(`${frontendUrl}/unavailable?reason=${restrictionReason}`);
+ return;
+ }
const failureUrl = this.config.getOrThrow('auth.oauthFailureRedirect', {
infer: true,
});
@@ -201,6 +210,18 @@ export class AuthController {
}
}
+ private accessRestrictionReason(err: unknown): 'region' | 'vpn' | null {
+ if (!(err instanceof ForbiddenException)) return null;
+ const body = err.getResponse();
+ const code =
+ typeof body === 'object' && body !== null
+ ? (body as { code?: string }).code
+ : undefined;
+ if (code === 'VPN_DETECTED') return 'vpn';
+ if (code === 'REGION_BLOCKED') return 'region';
+ return null;
+ }
+
private respondWithSession(
res: Response,
result: AuthResult,
diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts
index 32f5f7c..46c8234 100644
--- a/backend/src/modules/auth/auth.service.ts
+++ b/backend/src/modules/auth/auth.service.ts
@@ -170,6 +170,8 @@ export class AuthService {
profile: OAuthProfileDto,
ctx: RequestContext,
): Promise {
+ await this.assertRegionAllowed(ctx.ip);
+
if (!profile.email) {
throw new BadRequestException(
`${profile.provider} did not return an email; cannot sign in.`,
From 46f54d9a6b19bed58ce3a9862432bb32894c9c65 Mon Sep 17 00:00:00 2001
From: engdotme
Date: Thu, 11 Jun 2026 15:59:55 -0400
Subject: [PATCH 22/31] refactor: split cohort detail page into smaller
components
---
.../cohorts/components/detail-stats.tsx | 69 +++++
.../features/cohorts/components/detail.tsx | 247 ++----------------
.../components/student-progress-row.tsx | 53 ++++
.../features/cohorts/components/task-row.tsx | 75 ++++++
frontend/src/features/cohorts/hooks.ts | 63 +++++
frontend/src/features/cohorts/index.ts | 21 +-
frontend/src/features/cohorts/types.ts | 10 +
7 files changed, 310 insertions(+), 228 deletions(-)
create mode 100644 frontend/src/features/cohorts/components/detail-stats.tsx
create mode 100644 frontend/src/features/cohorts/components/student-progress-row.tsx
create mode 100644 frontend/src/features/cohorts/components/task-row.tsx
diff --git a/frontend/src/features/cohorts/components/detail-stats.tsx b/frontend/src/features/cohorts/components/detail-stats.tsx
new file mode 100644
index 0000000..e29615a
--- /dev/null
+++ b/frontend/src/features/cohorts/components/detail-stats.tsx
@@ -0,0 +1,69 @@
+import { CheckSquare, ClipboardList, Users } from "lucide-react";
+
+import { Card, CardContent, CardHeader, CardTitle } from "@components/ui/card";
+import type { Cohort } from "../types";
+
+export type CohortDetailStatsProps = {
+ cohort: Cohort;
+ totalTaskCount: number;
+ publishedTaskCount: number;
+ submissionCount: number;
+ pendingReviews: number;
+};
+
+export const CohortDetailStats = ({
+ cohort,
+ totalTaskCount,
+ publishedTaskCount,
+ submissionCount,
+ pendingReviews,
+}: CohortDetailStatsProps) => (
+
+ }
+ label="Students"
+ value={`${cohort.enrolledCount} / ${cohort.capacity}`}
+ />
+ }
+ label="Tasks"
+ value={`${publishedTaskCount} published`}
+ hint={`${totalTaskCount} total`}
+ />
+ }
+ label="Submissions"
+ value={`${submissionCount}`}
+ />
+ }
+ label="Pending Reviews"
+ value={`${pendingReviews}`}
+ />
+
+);
+
+const StatCard = ({
+ icon,
+ label,
+ value,
+ hint,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+ hint?: string;
+}) => (
+
+
+
+ {icon}
+ {label}
+
+
+
+ {value}
+ {hint && {hint}
}
+
+
+);
diff --git a/frontend/src/features/cohorts/components/detail.tsx b/frontend/src/features/cohorts/components/detail.tsx
index 3929102..d81b14c 100644
--- a/frontend/src/features/cohorts/components/detail.tsx
+++ b/frontend/src/features/cohorts/components/detail.tsx
@@ -1,42 +1,27 @@
"use client";
-import { useMemo, useState } from "react";
+import { useState } from "react";
import Link from "next/link";
import { format } from "date-fns";
-import { CheckSquare, ClipboardList, Users } from "lucide-react";
+import { Users } from "lucide-react";
-import { ClickableCard, LoadingState } from "@components/common";
+import { LoadingState } from "@components/common";
import { Badge } from "@components/ui/badge";
import { Button } from "@components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@components/ui/card";
-import { Progress } from "@components/ui/progress";
import { Tabs, TabsList, TabsTrigger } from "@components/ui/tabs";
import { EmptyState, PageContainer, PageHeader } from "@components/shared";
import { COHORT_STATUS_VARIANT } from "@constants/cohorts";
-import { TASK_TYPE_ICON } from "@constants/tasks";
-import {
- ReviewSheet,
- SubmissionStatusBadge,
- useSubmissions,
-} from "@features/submissions";
+import { ReviewSheet, useSubmissions } from "@features/submissions";
import { useTasks } from "@features/tasks";
-import type { Submission, SubmissionStatus, TaskStatus, TaskType } from "@types";
-import { useCohort, useEnrollments } from "../hooks";
+import { useCohort, useCohortStats, useEnrollments } from "../hooks";
+import { CohortDetailStats } from "./detail-stats";
+import { CohortStudentProgressRow } from "./student-progress-row";
+import { CohortTaskRow } from "./task-row";
import { Enrollments } from "./enrollments";
export type CohortDetailProps = { cohortId: number };
-type StudentProgress = {
- userId: number;
- name: string;
- email: string;
- approved: number;
- submitted: number;
- needsWork: number;
- todo: number;
-};
-
const backLink = (
{
const [reviewId, setReviewId] = useState(null);
const reviewing = submissions.find((s) => s.id === reviewId);
- const publishedTaskCount = useMemo(
- () => tasks.filter((t) => t.status === "published").length,
- [tasks],
- );
- const pendingReviews = useMemo(
- () => submissions.filter((s) => s.status === "submitted").length,
- [submissions],
+ const { publishedTaskCount, pendingReviews, progress } = useCohortStats(
+ enrollments,
+ tasks,
+ submissions,
);
- // Per-student progress: the latest submission per (student, task) decides
- // that task's current state. `submissions` arrives newest-first, so the
- // first time we see a (userId, taskId) pair is its latest status.
- const progress = useMemo(() => {
- const latest = new Map();
- for (const s of submissions) {
- if (!s.user) continue;
- const key = `${s.user.id}:${s.taskId}`;
- if (!latest.has(key)) latest.set(key, s.status);
- }
-
- return enrollments
- .map((e) => {
- const user = e.user;
- const counts = { approved: 0, submitted: 0, needsWork: 0 };
- for (const t of tasks) {
- if (t.status !== "published") continue;
- const status = latest.get(`${e.userId}:${t.id}`);
- if (status === "approved") counts.approved += 1;
- else if (status === "needs_work") counts.needsWork += 1;
- else if (status === "submitted") counts.submitted += 1;
- }
- const done = counts.approved + counts.submitted + counts.needsWork;
- return {
- userId: e.userId,
- name: user?.name ?? user?.email ?? "Unknown",
- email: user?.email ?? "",
- ...counts,
- todo: Math.max(0, publishedTaskCount - done),
- };
- })
- .sort((a, b) => b.approved - a.approved);
- }, [enrollments, tasks, submissions, publishedTaskCount]);
-
if (isLoading) {
return (
@@ -156,29 +104,13 @@ export const CohortDetail = ({ cohortId }: CohortDetailProps) => {
)}
-
- }
- label="Students"
- value={`${cohort.enrolledCount} / ${cohort.capacity}`}
- />
- }
- label="Tasks"
- value={`${publishedTaskCount} published`}
- hint={`${tasks.length} total`}
- />
- }
- label="Submissions"
- value={`${submissions.length}`}
- />
- }
- label="Pending Reviews"
- value={`${pendingReviews}`}
- />
-
+
setTab(v as typeof tab)}>
@@ -195,7 +127,7 @@ export const CohortDetail = ({ cohortId }: CohortDetailProps) => {
) : (
{progress.map((p) => (
-
{
{tasks.map((task) => {
const taskSubs = submissions.filter((s) => s.taskId === task.id);
return (
- {
);
};
-
-const StatCard = ({
- icon,
- label,
- value,
- hint,
-}: {
- icon: React.ReactNode;
- label: string;
- value: string;
- hint?: string;
-}) => (
-
-
-
- {icon}
- {label}
-
-
-
- {value}
- {hint && {hint}
}
-
-
-);
-
-const StudentRow = ({
- progress,
- total,
-}: {
- progress: StudentProgress;
- total: number;
-}) => {
- const pct = total > 0 ? Math.round((progress.approved / total) * 100) : 0;
- return (
-
-
-
-
- {progress.name[0]?.toUpperCase() ?? "?"}
-
-
-
{progress.name}
-
- {progress.email}
-
-
-
-
-
-
-
- {progress.approved}/{total} approved
-
- {pct}%
-
-
-
-
- {progress.submitted > 0 && (
- {progress.submitted} β³
- )}
- {progress.needsWork > 0 && (
-
- {progress.needsWork} β
-
- )}
-
-
-
-
- );
-};
-
-const TaskRow = ({
- title,
- type,
- status,
- dueDate,
- submissions,
- onReview,
-}: {
- title: string;
- type: TaskType;
- status: TaskStatus;
- dueDate: string | null;
- submissions: Submission[];
- onReview: (submission: Submission) => void;
-}) => {
- const Icon = TASK_TYPE_ICON[type] ?? CheckSquare;
- const pending = submissions.filter((s) => s.status === "submitted").length;
-
- return (
-
-
-
-
-
-
-
{title}
-
-
- {type}
-
- {submissions.length} submissions
- {pending > 0 && {pending} awaiting review}
- {dueDate && Due {format(new Date(dueDate), "MMM d")}}
-
-
-
- {status}
-
-
-
- {submissions.length > 0 && (
-
- {submissions.map((s) => (
-
onReview(s)}>
-
-
- {s.user?.name ?? s.user?.email ?? "Unknown student"}
-
-
- {format(new Date(s.createdAt), "MMM d, yyyy")}
-
-
-
-
- ))}
-
- )}
-
- );
-};
diff --git a/frontend/src/features/cohorts/components/student-progress-row.tsx b/frontend/src/features/cohorts/components/student-progress-row.tsx
new file mode 100644
index 0000000..5b1f4ed
--- /dev/null
+++ b/frontend/src/features/cohorts/components/student-progress-row.tsx
@@ -0,0 +1,53 @@
+import { Card } from "@components/ui/card";
+import { Progress } from "@components/ui/progress";
+import type { CohortStudentProgress } from "../types";
+
+export type CohortStudentProgressRowProps = {
+ progress: CohortStudentProgress;
+ total: number;
+};
+
+export const CohortStudentProgressRow = ({
+ progress,
+ total,
+}: CohortStudentProgressRowProps) => {
+ const pct = total > 0 ? Math.round((progress.approved / total) * 100) : 0;
+ return (
+
+
+
+
+ {progress.name[0]?.toUpperCase() ?? "?"}
+
+
+
{progress.name}
+
+ {progress.email}
+
+
+
+
+
+
+
+ {progress.approved}/{total} approved
+
+ {pct}%
+
+
+
+
+ {progress.submitted > 0 && (
+ {progress.submitted} β³
+ )}
+ {progress.needsWork > 0 && (
+
+ {progress.needsWork} β
+
+ )}
+
+
+
+
+ );
+};
diff --git a/frontend/src/features/cohorts/components/task-row.tsx b/frontend/src/features/cohorts/components/task-row.tsx
new file mode 100644
index 0000000..83310ea
--- /dev/null
+++ b/frontend/src/features/cohorts/components/task-row.tsx
@@ -0,0 +1,75 @@
+import { format } from "date-fns";
+import { CheckSquare } from "lucide-react";
+
+import { ClickableCard } from "@components/common";
+import { Badge } from "@components/ui/badge";
+import { Card } from "@components/ui/card";
+import { TASK_TYPE_ICON } from "@constants/tasks";
+import { SubmissionStatusBadge } from "@features/submissions";
+import type { Submission, TaskStatus, TaskType } from "@types";
+
+export type CohortTaskRowProps = {
+ title: string;
+ type: TaskType;
+ status: TaskStatus;
+ dueDate: string | null;
+ submissions: Submission[];
+ onReview: (submission: Submission) => void;
+};
+
+export const CohortTaskRow = ({
+ title,
+ type,
+ status,
+ dueDate,
+ submissions,
+ onReview,
+}: CohortTaskRowProps) => {
+ const Icon = TASK_TYPE_ICON[type] ?? CheckSquare;
+ const pending = submissions.filter((s) => s.status === "submitted").length;
+
+ return (
+
+
+
+
+
+
+
{title}
+
+
+ {type}
+
+ {submissions.length} submissions
+ {pending > 0 && {pending} awaiting review}
+ {dueDate && Due {format(new Date(dueDate), "MMM d")}}
+
+
+
+ {status}
+
+
+
+ {submissions.length > 0 && (
+
+ {submissions.map((s) => (
+
onReview(s)}>
+
+
+ {s.user?.name ?? s.user?.email ?? "Unknown student"}
+
+
+ {format(new Date(s.createdAt), "MMM d, yyyy")}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/frontend/src/features/cohorts/hooks.ts b/frontend/src/features/cohorts/hooks.ts
index 2b42080..5670614 100644
--- a/frontend/src/features/cohorts/hooks.ts
+++ b/frontend/src/features/cohorts/hooks.ts
@@ -1,8 +1,12 @@
"use client";
+import { useMemo } from "react";
+
import { useAsyncResource } from "@hooks/use-async-resource";
+import type { Enrollment, Submission, SubmissionStatus, Task } from "@types";
import { getCohort, listCohorts, listEnrollments } from "./api";
+import type { CohortStudentProgress } from "./types";
export const useCohorts = () => useAsyncResource(() => listCohorts(), []);
@@ -21,3 +25,62 @@ export const useEnrollments = (cohortId: number | undefined) =>
cohortId == null ? Promise.resolve([]) : listEnrollments(cohortId),
[cohortId],
);
+
+export type CohortStats = {
+ publishedTaskCount: number;
+ pendingReviews: number;
+ progress: CohortStudentProgress[];
+};
+
+/** Derives per-student progress and headline counts for the cohort detail page. */
+export const useCohortStats = (
+ enrollments: Enrollment[],
+ tasks: Task[],
+ submissions: Submission[],
+): CohortStats => {
+ const publishedTaskCount = useMemo(
+ () => tasks.filter((t) => t.status === "published").length,
+ [tasks],
+ );
+
+ const pendingReviews = useMemo(
+ () => submissions.filter((s) => s.status === "submitted").length,
+ [submissions],
+ );
+
+ // Per-student progress: the latest submission per (student, task) decides
+ // that task's current state. `submissions` arrives newest-first, so the
+ // first time we see a (userId, taskId) pair is its latest status.
+ const progress = useMemo(() => {
+ const latest = new Map();
+ for (const s of submissions) {
+ if (!s.user) continue;
+ const key = `${s.user.id}:${s.taskId}`;
+ if (!latest.has(key)) latest.set(key, s.status);
+ }
+
+ return enrollments
+ .map((e) => {
+ const user = e.user;
+ const counts = { approved: 0, submitted: 0, needsWork: 0 };
+ for (const t of tasks) {
+ if (t.status !== "published") continue;
+ const status = latest.get(`${e.userId}:${t.id}`);
+ if (status === "approved") counts.approved += 1;
+ else if (status === "needs_work") counts.needsWork += 1;
+ else if (status === "submitted") counts.submitted += 1;
+ }
+ const done = counts.approved + counts.submitted + counts.needsWork;
+ return {
+ userId: e.userId,
+ name: user?.name ?? user?.email ?? "Unknown",
+ email: user?.email ?? "",
+ ...counts,
+ todo: Math.max(0, publishedTaskCount - done),
+ };
+ })
+ .sort((a, b) => b.approved - a.approved);
+ }, [enrollments, tasks, submissions, publishedTaskCount]);
+
+ return { publishedTaskCount, pendingReviews, progress };
+};
diff --git a/frontend/src/features/cohorts/index.ts b/frontend/src/features/cohorts/index.ts
index 05b2f88..8e71fd7 100644
--- a/frontend/src/features/cohorts/index.ts
+++ b/frontend/src/features/cohorts/index.ts
@@ -1,4 +1,9 @@
-export type { Cohort, CohortStatus, Enrollment } from "./types";
+export type {
+ Cohort,
+ CohortStatus,
+ CohortStudentProgress,
+ Enrollment,
+} from "./types";
export { COHORT_STATUS_OPTIONS, COHORT_STATUS_VARIANT } from "@constants/cohorts";
export {
listCohorts,
@@ -10,7 +15,13 @@ export {
enrollStudent,
type CohortInput,
} from "./api";
-export { useCohorts, useCohort, useEnrollments } from "./hooks";
+export {
+ useCohorts,
+ useCohort,
+ useCohortStats,
+ useEnrollments,
+ type CohortStats,
+} from "./hooks";
export { CohortFormDialog } from "./components/form-dialog";
export type { CohortFormDialogProps } from "./components/form-dialog";
@@ -20,3 +31,9 @@ export { CohortRow } from "./components/row";
export type { CohortRowProps } from "./components/row";
export { CohortDetail } from "./components/detail";
export type { CohortDetailProps } from "./components/detail";
+export { CohortDetailStats } from "./components/detail-stats";
+export type { CohortDetailStatsProps } from "./components/detail-stats";
+export { CohortStudentProgressRow } from "./components/student-progress-row";
+export type { CohortStudentProgressRowProps } from "./components/student-progress-row";
+export { CohortTaskRow } from "./components/task-row";
+export type { CohortTaskRowProps } from "./components/task-row";
diff --git a/frontend/src/features/cohorts/types.ts b/frontend/src/features/cohorts/types.ts
index 145e953..7bb6827 100644
--- a/frontend/src/features/cohorts/types.ts
+++ b/frontend/src/features/cohorts/types.ts
@@ -1 +1,11 @@
export type { Cohort, CohortStatus, Enrollment } from "@types";
+
+export type CohortStudentProgress = {
+ userId: number;
+ name: string;
+ email: string;
+ approved: number;
+ submitted: number;
+ needsWork: number;
+ todo: number;
+};
From a5e5ed9036a84b476a73f67be99d4720f8d29c1c Mon Sep 17 00:00:00 2001
From: engdotme
Date: Thu, 11 Jun 2026 16:11:57 -0400
Subject: [PATCH 23/31] refactor: split notification bell into panel and row
component
---
.../components/notification-bell.tsx | 246 +-----------------
.../components/notification-panel.tsx | 171 ++++++++++++
.../components/notification-row.tsx | 83 ++++++
3 files changed, 257 insertions(+), 243 deletions(-)
create mode 100644 frontend/src/features/notifications/components/notification-panel.tsx
create mode 100644 frontend/src/features/notifications/components/notification-row.tsx
diff --git a/frontend/src/features/notifications/components/notification-bell.tsx b/frontend/src/features/notifications/components/notification-bell.tsx
index 78a0d12..a65e0e1 100644
--- a/frontend/src/features/notifications/components/notification-bell.tsx
+++ b/frontend/src/features/notifications/components/notification-bell.tsx
@@ -1,34 +1,21 @@
"use client";
import { useState } from "react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { formatDistanceToNow } from "date-fns";
-import { Bell, CheckCheck, Trash2 } from "lucide-react";
-import { toast } from "sonner";
+import { Bell } from "lucide-react";
import { Badge } from "@components/ui/badge";
import { Button } from "@components/ui/button";
import {
Sheet,
- SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@components/ui/sheet";
-import { Skeleton } from "@components/ui/skeleton";
import { useCurrentUser } from "@contexts";
-import { cn } from "@utils";
-import type { Notification } from "@types";
-import {
- deleteNotification,
- markAllNotificationsRead,
- markNotificationRead,
-} from "../api";
-import { useNotifications, useUnreadNotificationCount } from "../hooks";
-import { FALLBACK_NOTIFICATION_ICON, NOTIFICATION_ICONS } from "../icons";
+import { useUnreadNotificationCount } from "../hooks";
+import { NotificationPanel } from "./notification-panel";
export function NotificationBell() {
const [open, setOpen] = useState(false);
@@ -78,230 +65,3 @@ export function NotificationBell() {
);
}
-
-interface NotificationPanelProps {
- viewAllHref: string;
- onCountChange: (count: number) => void;
- onRefreshCount: () => void;
- onNavigate: () => void;
-}
-
-function NotificationPanel({
- viewAllHref,
- onCountChange,
- onRefreshCount,
- onNavigate,
-}: NotificationPanelProps) {
- const router = useRouter();
- const { data, isLoading, error, refetch } = useNotifications();
- const [busy, setBusy] = useState(false);
- const [deletingId, setDeletingId] = useState(null);
-
- const notifications = data ?? [];
- const hasUnread = notifications.some((n) => n.readAt === null);
-
- const handleItemClick = async (notification: Notification) => {
- if (notification.readAt === null) {
- try {
- await markNotificationRead(notification.id);
- refetch();
- onRefreshCount();
- } catch {
- // Navigation should still proceed even if the read flag fails to save.
- }
- }
- if (notification.link) {
- onNavigate();
- router.push(notification.link);
- }
- };
-
- const handleMarkAllRead = async () => {
- setBusy(true);
- try {
- await markAllNotificationsRead();
- onCountChange(0);
- refetch();
- } catch {
- toast.error("Couldn't mark notifications as read.");
- } finally {
- setBusy(false);
- }
- };
-
- const handleDelete = async (e: React.MouseEvent, id: number) => {
- e.stopPropagation();
- setDeletingId(id);
- try {
- await deleteNotification(id);
- refetch();
- onRefreshCount();
- } catch {
- toast.error("Couldn't delete notification.");
- } finally {
- setDeletingId(null);
- }
- };
-
- return (
- <>
-
-
- {notifications.length > 0
- ? `${notifications.length} total`
- : "All caught up"}
-
-
-
-
-
- {isLoading ? (
-
- ) : error ? (
-
- Couldn't load notifications.{" "}
-
-
- ) : notifications.length === 0 ? (
-
You have no notifications yet.
- ) : (
-
- {notifications.map((notification) => (
- void handleItemClick(notification)}
- onDelete={(e) => void handleDelete(e, notification.id)}
- />
- ))}
-
- )}
-
-
-
-
-
-
-
- >
- );
-}
-
-interface NotificationRowProps {
- notification: Notification;
- deleting: boolean;
- onClick: () => void;
- onDelete: (e: React.MouseEvent) => void;
-}
-
-function NotificationRow({
- notification,
- deleting,
- onClick,
- onDelete,
-}: NotificationRowProps) {
- const Icon =
- NOTIFICATION_ICONS[notification.type] ?? FALLBACK_NOTIFICATION_ICON;
- const unread = notification.readAt === null;
-
- return (
-
-
-
-
- {formatDistanceToNow(new Date(notification.createdAt), {
- addSuffix: true,
- })}
-
-
-
-
- );
-}
-
-function PanelMessage({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-function PanelSkeleton() {
- return (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
- );
-}
diff --git a/frontend/src/features/notifications/components/notification-panel.tsx b/frontend/src/features/notifications/components/notification-panel.tsx
new file mode 100644
index 0000000..cdce4ac
--- /dev/null
+++ b/frontend/src/features/notifications/components/notification-panel.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { CheckCheck } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@components/ui/button";
+import { SheetClose } from "@components/ui/sheet";
+import { Skeleton } from "@components/ui/skeleton";
+import type { Notification } from "@types";
+
+import {
+ deleteNotification,
+ markAllNotificationsRead,
+ markNotificationRead,
+} from "../api";
+import { useNotifications } from "../hooks";
+import { NotificationRow } from "./notification-row";
+
+export interface NotificationPanelProps {
+ viewAllHref: string;
+ onCountChange: (count: number) => void;
+ onRefreshCount: () => void;
+ onNavigate: () => void;
+}
+
+export function NotificationPanel({
+ viewAllHref,
+ onCountChange,
+ onRefreshCount,
+ onNavigate,
+}: NotificationPanelProps) {
+ const router = useRouter();
+ const { data, isLoading, error, refetch } = useNotifications();
+ const [busy, setBusy] = useState(false);
+ const [deletingId, setDeletingId] = useState(null);
+
+ const notifications = data ?? [];
+ const hasUnread = notifications.some((n) => n.readAt === null);
+
+ const handleItemClick = async (notification: Notification) => {
+ if (notification.readAt === null) {
+ try {
+ await markNotificationRead(notification.id);
+ refetch();
+ onRefreshCount();
+ } catch {
+ // Navigation should still proceed even if the read flag fails to save.
+ }
+ }
+ if (notification.link) {
+ onNavigate();
+ router.push(notification.link);
+ }
+ };
+
+ const handleMarkAllRead = async () => {
+ setBusy(true);
+ try {
+ await markAllNotificationsRead();
+ onCountChange(0);
+ refetch();
+ } catch {
+ toast.error("Couldn't mark notifications as read.");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleDelete = async (e: React.MouseEvent, id: number) => {
+ e.stopPropagation();
+ setDeletingId(id);
+ try {
+ await deleteNotification(id);
+ refetch();
+ onRefreshCount();
+ } catch {
+ toast.error("Couldn't delete notification.");
+ } finally {
+ setDeletingId(null);
+ }
+ };
+
+ return (
+ <>
+
+
+ {notifications.length > 0
+ ? `${notifications.length} total`
+ : "All caught up"}
+
+
+
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+ Couldn't load notifications.{" "}
+
+
+ ) : notifications.length === 0 ? (
+
You have no notifications yet.
+ ) : (
+
+ {notifications.map((notification) => (
+ void handleItemClick(notification)}
+ onDelete={(e) => void handleDelete(e, notification.id)}
+ />
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function PanelMessage({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function PanelSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/features/notifications/components/notification-row.tsx b/frontend/src/features/notifications/components/notification-row.tsx
new file mode 100644
index 0000000..408bfc7
--- /dev/null
+++ b/frontend/src/features/notifications/components/notification-row.tsx
@@ -0,0 +1,83 @@
+import { formatDistanceToNow } from "date-fns";
+import { Trash2 } from "lucide-react";
+
+import { cn } from "@utils";
+import type { Notification } from "@types";
+
+import { FALLBACK_NOTIFICATION_ICON, NOTIFICATION_ICONS } from "../icons";
+
+export interface NotificationRowProps {
+ notification: Notification;
+ deleting: boolean;
+ onClick: () => void;
+ onDelete: (e: React.MouseEvent) => void;
+}
+
+export function NotificationRow({
+ notification,
+ deleting,
+ onClick,
+ onDelete,
+}: NotificationRowProps) {
+ const Icon =
+ NOTIFICATION_ICONS[notification.type] ?? FALLBACK_NOTIFICATION_ICON;
+ const unread = notification.readAt === null;
+
+ return (
+
+
+
+
+ {formatDistanceToNow(new Date(notification.createdAt), {
+ addSuffix: true,
+ })}
+
+
+
+
+ );
+}
From a36606e8a4b1c6831ff034efab31ef4ae7f577f4 Mon Sep 17 00:00:00 2001
From: engdotme
Date: Thu, 11 Jun 2026 16:30:04 -0400
Subject: [PATCH 24/31] refactor: split application detail page into focused
components
---
.../components/application-content.tsx | 109 ++++++++
.../components/application-detail-page.tsx | 251 +-----------------
.../components/application-review-panel.tsx | 88 ++++++
.../components/application-wallets-card.tsx | 37 +++
.../applications/components/copy-button.tsx | 25 ++
frontend/src/features/applications/index.ts | 6 +
6 files changed, 279 insertions(+), 237 deletions(-)
create mode 100644 frontend/src/features/applications/components/application-content.tsx
create mode 100644 frontend/src/features/applications/components/application-review-panel.tsx
create mode 100644 frontend/src/features/applications/components/application-wallets-card.tsx
create mode 100644 frontend/src/features/applications/components/copy-button.tsx
diff --git a/frontend/src/features/applications/components/application-content.tsx b/frontend/src/features/applications/components/application-content.tsx
new file mode 100644
index 0000000..5d0ccd2
--- /dev/null
+++ b/frontend/src/features/applications/components/application-content.tsx
@@ -0,0 +1,109 @@
+import { ProseBlock, SectionTitle } from "@components/common";
+import { isSafeHref } from "@utils";
+import { countryLabel } from "@constants/shared/countries";
+import type { Application } from "../types";
+import { CopyButton } from "./copy-button";
+
+const SOCIAL_LINKS = [
+ { key: "linkedin" as const, label: "LinkedIn" },
+ { key: "github" as const, label: "GitHub" },
+ { key: "twitter" as const, label: "Twitter / X" },
+ { key: "facebook" as const, label: "Facebook" },
+ { key: "portfolio" as const, label: "Portfolio" },
+] as const;
+
+export type ApplicationContentProps = { application: Application };
+
+export function ApplicationContent({ application }: ApplicationContentProps) {
+ const hasSocialLinks = SOCIAL_LINKS.some(({ key }) => !!application[key]);
+ const hasContact =
+ application.telegram || application.whatsapp || application.country;
+
+ return (
+
+ {application.motivation && (
+
+
Motivation
+
+ {application.motivation}
+
+
+ )}
+ {application.background && (
+
+
Background
+
+ {application.background}
+
+
+ )}
+ {application.experience && (
+
+
Experience
+
+ {application.experience}
+
+
+ )}
+ {hasSocialLinks && (
+
+
Social Profiles
+
+ {SOCIAL_LINKS.filter(({ key }) => !!application[key]).map(
+ ({ key, label }) => (
+
+ ),
+ )}
+
+
+ )}
+ {hasContact && (
+
+
Contact
+
+ {application.telegram && (
+
+ Telegram
+
+ {application.telegram}
+
+
+
+ )}
+ {application.whatsapp && (
+
+ WhatsApp
+
+ {application.whatsapp}
+
+
+
+ )}
+ {application.country && (
+
+ Country
+
+ {countryLabel(application.country)}
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/features/applications/components/application-detail-page.tsx b/frontend/src/features/applications/components/application-detail-page.tsx
index b1d8470..d829d96 100644
--- a/frontend/src/features/applications/components/application-detail-page.tsx
+++ b/frontend/src/features/applications/components/application-detail-page.tsx
@@ -1,97 +1,26 @@
"use client";
-import { useState } from "react";
import Link from "next/link";
import { format } from "date-fns";
-import { Copy, Check } from "lucide-react";
-import { toast } from "sonner";
-import { ProseBlock, SectionTitle } from "@components/common";
-import { Button } from "@components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@components/ui/card";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@components/ui/select";
import { Skeleton } from "@components/ui/skeleton";
-import { Textarea } from "@components/ui/textarea";
-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 { useAsyncResource } from "@hooks/use-async-resource";
import { getApplication } from "../api";
-import { useUpdateApplicationStatus } from "../hooks";
import { ApplicationStatusBadge } from "./status-badge";
+import { ApplicationContent } from "./application-content";
+import { ApplicationReviewPanel } from "./application-review-panel";
+import { ApplicationWalletsCard } from "./application-wallets-card";
-const SOCIAL_LINKS = [
- { key: "linkedin" as const, label: "LinkedIn" },
- { key: "github" as const, label: "GitHub" },
- { key: "twitter" as const, label: "Twitter / X" },
- { key: "facebook" as const, label: "Facebook" },
- { key: "portfolio" as const, label: "Portfolio" },
-] as const;
-
-function CopyButton({ text }: { text: string }) {
- const [copied, setCopied] = useState(false);
- const copy = () => {
- void navigator.clipboard.writeText(text).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- });
- };
- return (
-
- );
-}
-
-const CHAIN_LABELS: Record = {
- evm: "EVM",
- solana: "Solana",
- tron: "Tron",
-};
-
-interface Props {
- id: number;
-}
+export type ApplicationDetailPageProps = { id: number };
-export const ApplicationDetailPage = ({ id }: Props) => {
+export const ApplicationDetailPage = ({ id }: ApplicationDetailPageProps) => {
const { data: application, isLoading } = useAsyncResource(
() => getApplication(id),
[id],
);
- const [status, setStatus] = useState(null);
- const [reviewerNote, setReviewerNote] = useState(null);
-
- const { update, isPending: isSaving } = useUpdateApplicationStatus();
-
- const resolvedStatus = (status ?? application?.status) as ApplicationStatus | undefined;
- const resolvedNote = reviewerNote ?? application?.reviewerNote ?? "";
-
- const handleSave = async () => {
- if (!application) return;
- try {
- await update(application.id, {
- status: resolvedStatus!,
- reviewerNote: resolvedNote || null,
- });
- toast.success("Application updated");
- } catch {
- toast.error("Failed to update application");
- }
- };
-
if (isLoading || !application) {
return (
@@ -102,13 +31,8 @@ export const ApplicationDetailPage = ({ id }: Props) => {
);
}
- const hasSocialLinks = SOCIAL_LINKS.some(({ key }) => !!application[key]);
- const hasContact = application.telegram || application.whatsapp || application.country;
- const hasWallets = application.wallets && application.wallets.length > 0;
-
return (
- {/* Back + header */}
{
- {/* Video */}
{application.videoUrl && (
@@ -146,163 +69,17 @@ export const ApplicationDetailPage = ({ id }: Props) => {
)}
- {/* Left: application content */}
-
- {application.motivation && (
-
-
Motivation
-
- {application.motivation}
-
-
- )}
- {application.background && (
-
-
Background
-
- {application.background}
-
-
- )}
- {application.experience && (
-
-
Experience
-
- {application.experience}
-
-
- )}
- {hasSocialLinks && (
-
-
Social Profiles
-
- {SOCIAL_LINKS.filter(({ key }) => !!application[key]).map(
- ({ key, label }) => (
-
-
{label}
- {isSafeHref(application[key]!) ? (
-
- {application[key]}
-
- ) : (
-
- {application[key]}
-
- )}
-
- ),
- )}
-
-
- )}
- {hasContact && (
-
-
Contact
-
- {application.telegram && (
-
- Telegram
-
- {application.telegram}
-
-
-
- )}
- {application.whatsapp && (
-
- WhatsApp
-
- {application.whatsapp}
-
-
-
- )}
- {application.country && (
-
- Country
-
- {countryLabel(application.country)}
-
-
- )}
-
-
- )}
+
- {/* Right: admin panel + profiles */}
- {/* Admin controls */}
-
-
- Review
-
-
-
-
Status
-
-
-
-
-
-
-
-
-
- {/* Wallets */}
- {hasWallets && (
-
-
- Wallet Addresses
-
-
- {application.wallets!.map((w, i) => (
-
-
- {CHAIN_LABELS[w.chain] ?? w.chain}
-
-
- {w.address}
-
-
-
- ))}
-
-
- )}
+
+
diff --git a/frontend/src/features/applications/components/application-review-panel.tsx b/frontend/src/features/applications/components/application-review-panel.tsx
new file mode 100644
index 0000000..9e4ae8a
--- /dev/null
+++ b/frontend/src/features/applications/components/application-review-panel.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@components/ui/card";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@components/ui/select";
+import { Textarea } from "@components/ui/textarea";
+import { APPLICATION_STATUS_OPTIONS } from "@constants/applications";
+import type { ApplicationStatus } from "../types";
+import { useUpdateApplicationStatus } from "../hooks";
+
+export type ApplicationReviewPanelProps = {
+ applicationId: number;
+ initialStatus: ApplicationStatus;
+ initialNote: string;
+};
+
+export function ApplicationReviewPanel({
+ applicationId,
+ initialStatus,
+ initialNote,
+}: ApplicationReviewPanelProps) {
+ const [status, setStatus] = useState(initialStatus);
+ const [note, setNote] = useState(initialNote);
+ const { update, isPending } = useUpdateApplicationStatus();
+
+ const handleSave = async () => {
+ try {
+ await update(applicationId, {
+ status,
+ reviewerNote: note || null,
+ });
+ toast.success("Application updated");
+ } catch {
+ toast.error("Failed to update application");
+ }
+ };
+
+ return (
+
+
+ Review
+
+
+
+
Status
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/applications/components/application-wallets-card.tsx b/frontend/src/features/applications/components/application-wallets-card.tsx
new file mode 100644
index 0000000..c9e3d9f
--- /dev/null
+++ b/frontend/src/features/applications/components/application-wallets-card.tsx
@@ -0,0 +1,37 @@
+import { Card, CardContent, CardHeader, CardTitle } from "@components/ui/card";
+import { CopyButton } from "./copy-button";
+
+const CHAIN_LABELS: Record = {
+ evm: "EVM",
+ solana: "Solana",
+ tron: "Tron",
+};
+
+export type ApplicationWalletsCardProps = {
+ wallets: Array<{ chain: string; address: string }>;
+};
+
+export function ApplicationWalletsCard({ wallets }: ApplicationWalletsCardProps) {
+ if (wallets.length === 0) return null;
+
+ return (
+
+
+ Wallet Addresses
+
+
+ {wallets.map((w, i) => (
+
+
+ {CHAIN_LABELS[w.chain] ?? w.chain}
+
+
+ {w.address}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/features/applications/components/copy-button.tsx b/frontend/src/features/applications/components/copy-button.tsx
new file mode 100644
index 0000000..7cabd3c
--- /dev/null
+++ b/frontend/src/features/applications/components/copy-button.tsx
@@ -0,0 +1,25 @@
+import { useState } from "react";
+import { Check, Copy } from "lucide-react";
+
+export function CopyButton({ text }: { text: string }) {
+ const [copied, setCopied] = useState(false);
+ const copy = () => {
+ void navigator.clipboard.writeText(text).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+ return (
+
+ );
+}
diff --git a/frontend/src/features/applications/index.ts b/frontend/src/features/applications/index.ts
index bfab83f..e9421c2 100644
--- a/frontend/src/features/applications/index.ts
+++ b/frontend/src/features/applications/index.ts
@@ -29,3 +29,9 @@ export { ApplicationList } from "./components/list";
export type { ApplicationListProps } from "./components/list";
export { Wizard } from "./components/wizard";
export { WizardStep } from "./components/wizard/step";
+export { ApplicationContent } from "./components/application-content";
+export type { ApplicationContentProps } from "./components/application-content";
+export { ApplicationReviewPanel } from "./components/application-review-panel";
+export type { ApplicationReviewPanelProps } from "./components/application-review-panel";
+export { ApplicationWalletsCard } from "./components/application-wallets-card";
+export type { ApplicationWalletsCardProps } from "./components/application-wallets-card";
From 7d12d7ff5e06c1bd8ce3d9c605794facad6b960a Mon Sep 17 00:00:00 2001
From: engdotme
Date: Thu, 11 Jun 2026 17:26:02 -0400
Subject: [PATCH 25/31] refactor: extract avatar upload from profile form,
collapse social fields
---
.../components/profile-avatar-upload.tsx | 107 ++++++++
.../profile/components/profile-form.tsx | 243 ++++--------------
frontend/src/features/profile/index.ts | 2 +
3 files changed, 160 insertions(+), 192 deletions(-)
create mode 100644 frontend/src/features/profile/components/profile-avatar-upload.tsx
diff --git a/frontend/src/features/profile/components/profile-avatar-upload.tsx b/frontend/src/features/profile/components/profile-avatar-upload.tsx
new file mode 100644
index 0000000..fb37e7a
--- /dev/null
+++ b/frontend/src/features/profile/components/profile-avatar-upload.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@components/ui/button";
+import { ApiError } from "@lib/api-client";
+import { resolveAssetUrl } from "@lib/config";
+import { initials } from "@utils";
+import type { UserProfile } from "@types";
+
+import { uploadAvatar } from "../api";
+
+const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
+const MAX_AVATAR_BYTES = 2 * 1024 * 1024; // 2 MB
+
+export type ProfileAvatarUploadProps = {
+ user: UserProfile;
+ onUploaded?: () => void | Promise;
+};
+
+export const ProfileAvatarUpload = ({
+ user,
+ onUploaded,
+}: ProfileAvatarUploadProps) => {
+ const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl ?? "");
+ const [isUploading, setIsUploading] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const handleAvatarChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ // Reset so selecting the same file again still fires onChange.
+ e.target.value = "";
+ if (!file) return;
+
+ if (!ACCEPTED_TYPES.includes(file.type)) {
+ toast.error("Please choose a JPEG, PNG, or WebP image.");
+ return;
+ }
+ if (file.size > MAX_AVATAR_BYTES) {
+ toast.error("Image is too large. The maximum size is 2 MB.");
+ return;
+ }
+
+ setIsUploading(true);
+ try {
+ const updated = await uploadAvatar(file);
+ setAvatarUrl(updated.avatarUrl ?? "");
+ toast.success("Avatar updated.");
+ await onUploaded?.();
+ } catch (err) {
+ toast.error(
+ err instanceof ApiError ? err.message : "Failed to upload your avatar.",
+ );
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ return (
+
+ {avatarUrl.trim() ? (
+ // eslint-disable-next-line @next/next/no-img-element -- user avatar served by the API, not a static asset
+
})
+ ) : (
+
+ {initials(user.name, user.email)}
+
+ )}
+
+
+
{user.name ?? "Unnamed"}
+
{user.email}
+
+
+
+
+
+ JPEG, PNG, or WebP. Max 2 MB.
+
+
+
+
+ );
+};
diff --git a/frontend/src/features/profile/components/profile-form.tsx b/frontend/src/features/profile/components/profile-form.tsx
index 1d21086..e29a49f 100644
--- a/frontend/src/features/profile/components/profile-form.tsx
+++ b/frontend/src/features/profile/components/profile-form.tsx
@@ -1,6 +1,5 @@
"use client";
-import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
@@ -19,25 +18,18 @@ import { Card, CardContent } from "@components/ui/card";
import { Input } from "@components/ui/input";
import { Textarea } from "@components/ui/textarea";
import { ApiError } from "@lib/api-client";
-import { resolveAssetUrl } from "@lib/config";
-import { initials } from "@utils";
import type { UserProfile } from "@types";
-import { updateProfile, uploadAvatar } from "../api";
+import { updateProfile } from "../api";
import { PROFILE_FORM_SCHEMA, type ProfileFormValues } from "../form-schema";
import type { ProfileUpdate } from "../types";
+import { ProfileAvatarUpload } from "./profile-avatar-upload";
export type ProfileFormProps = {
user: UserProfile;
- /** Called after a successful save so the caller can refresh the session user. */
onSaved?: () => void | Promise;
};
-const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
-const MAX_AVATAR_BYTES = 2 * 1024 * 1024; // 2 MB
-
-// The URL/handle fields are validated server-side and only sent when filled, so
-// a blank field clears rather than fails. These map form keys to payload keys.
const OPTIONAL_FIELDS = [
"linkedin",
"twitter",
@@ -48,6 +40,34 @@ const OPTIONAL_FIELDS = [
"whatsapp",
] as const;
+type SocialFieldConfig = {
+ name: keyof ProfileFormValues;
+ label: string;
+ placeholder: string;
+ description?: string;
+};
+
+const SOCIAL_FIELDS: SocialFieldConfig[] = [
+ { name: "github", label: "GitHub", placeholder: "https://github.com/you" },
+ { name: "linkedin", label: "LinkedIn", placeholder: "https://linkedin.com/in/you" },
+ { name: "twitter", label: "X (Twitter)", placeholder: "https://x.com/you" },
+ { name: "facebook", label: "Facebook", placeholder: "https://facebook.com/you" },
+ { name: "telegram", label: "Telegram", placeholder: "@yourusername" },
+ {
+ name: "whatsapp",
+ label: "WhatsApp",
+ placeholder: "+1234567890",
+ description: "Include country code, e.g. +1234567890",
+ },
+ {
+ name: "portfolio",
+ label: "Portfolio",
+ placeholder: "https://yoursite.com",
+ description:
+ "If you don't have a portfolio website yet, you can leave this blank β your profile can still be considered complete.",
+ },
+];
+
export const ProfileForm = ({ user, onSaved }: ProfileFormProps) => {
const form = useForm({
resolver: zodResolver(PROFILE_FORM_SCHEMA),
@@ -64,10 +84,6 @@ export const ProfileForm = ({ user, onSaved }: ProfileFormProps) => {
},
});
- const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl ?? "");
- const [isUploading, setIsUploading] = useState(false);
- const fileInputRef = useRef(null);
-
const onSubmit = async (data: ProfileFormValues) => {
const payload: ProfileUpdate = {
name: data.name.trim(),
@@ -77,7 +93,6 @@ export const ProfileForm = ({ user, onSaved }: ProfileFormProps) => {
const value = data[field].trim();
if (value) payload[field] = value;
}
-
try {
await updateProfile(payload);
toast.success("Profile saved.");
@@ -89,88 +104,10 @@ export const ProfileForm = ({ user, onSaved }: ProfileFormProps) => {
}
};
- const handleAvatarChange = async (
- e: React.ChangeEvent,
- ) => {
- const file = e.target.files?.[0];
- // Reset so selecting the same file again still fires onChange.
- e.target.value = "";
- if (!file) return;
-
- if (!ACCEPTED_TYPES.includes(file.type)) {
- toast.error("Please choose a JPEG, PNG, or WebP image.");
- return;
- }
- if (file.size > MAX_AVATAR_BYTES) {
- toast.error("Image is too large. The maximum size is 2 MB.");
- return;
- }
-
- setIsUploading(true);
- try {
- const updated = await uploadAvatar(file);
- setAvatarUrl(updated.avatarUrl ?? "");
- toast.success("Avatar updated.");
- await onSaved?.();
- } catch (err) {
- toast.error(
- err instanceof ApiError ? err.message : "Failed to upload your avatar.",
- );
- } finally {
- setIsUploading(false);
- }
- };
-
return (
-
- {avatarUrl.trim() ? (
- // eslint-disable-next-line @next/next/no-img-element -- user avatar served by the API, not a static asset
-
})
- ) : (
-
- {initials(user.name, user.email)}
-
- )}
-
-
-
{user.name ?? "Unnamed"}
-
- {user.email}
-
-
-
-
-
-
- JPEG, PNG, or WebP. Max 2 MB.
-
-
-
-
+