diff --git a/backend/.env.example b/backend/.env.example
index 352f53a..78eedab 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).
@@ -64,3 +67,15 @@ SMTP_PASS=
# "true" or "1" to use TLS.
SMTP_SECURE=false
EMAIL_FROM="no-reply@forgeng.local"
+
+# --- Reverse proxy / trust ---
+# Number of trusted reverse-proxy hops in front of this server (nginx, load
+# balancer, etc.). Set to 1 for a single proxy, 2 for two hops, 0 for direct.
+# When set, Express reads req.ip from X-Forwarded-For correctly and clients
+# cannot spoof their IP by injecting a fake XFF header.
+TRUST_PROXY=1
+
+# --- 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/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" }],
},
diff --git a/backend/package.json b/backend/package.json
index 9e845fa..fa2c86f 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",
@@ -38,6 +35,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",
@@ -73,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/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/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/config/config.types.ts b/backend/src/config/config.types.ts
index 744ea0f..a1664b8 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;
@@ -28,6 +29,15 @@ export interface AppConfiguration {
};
smtp: SmtpConfig | null;
emailFrom: string;
+ ipQualityScore: {
+ apiKey: string | undefined;
+ };
+ /**
+ * Number of trusted reverse-proxy hops in front of the server.
+ * Set to 1 when behind a single nginx/load-balancer, 2 for two hops, etc.
+ * 0 = no proxy (default, req.ip is the socket IP).
+ */
+ trustProxy: number;
}
export interface OAuthProviderConfig {
diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts
index 6f5d4c3..fa4f47c 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,
@@ -126,5 +128,9 @@ export default (): AppConfiguration => {
},
smtp: parseSmtp(),
emailFrom: process.env.EMAIL_FROM ?? DEFAULT_EMAIL_FROM,
+ ipQualityScore: {
+ apiKey: process.env.IPQUALITYSCORE_API_KEY,
+ },
+ trustProxy: parsePort(process.env.TRUST_PROXY, 0),
};
};
diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts
index d01b3f2..b18cb99 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;
@@ -138,6 +142,15 @@ class EnvironmentVariables {
@IsOptional()
@IsString()
EMAIL_FROM?: string;
+
+ @IsOptional()
+ @IsString()
+ IPQUALITYSCORE_API_KEY?: string;
+
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ TRUST_PROXY?: number;
}
/** Validates `process.env` at startup; fails fast on missing required vars. */
diff --git a/backend/src/main.ts b/backend/src/main.ts
index 5dd3e5f..74dff60 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';
@@ -22,6 +22,12 @@ async function bootstrap(): Promise {
const corsOrigin = config.getOrThrow('corsOrigin', { infer: true });
const uploadsDir = config.getOrThrow('uploadsDir', { infer: true });
+ // Tell Express how many trusted reverse-proxy hops sit in front of this
+ // server. When set, req.ip is derived from X-Forwarded-For correctly and
+ // clients cannot spoof it by injecting a fake XFF header value.
+ const trustProxy = config.getOrThrow('trustProxy', { infer: true });
+ if (trustProxy > 0) app.set('trust proxy', trustProxy);
+
app.setGlobalPrefix('api');
// Serve uploaded files (avatars, etc.). The global `/api` prefix only applies
@@ -54,9 +60,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/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..f914bcf 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,
@@ -13,9 +14,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';
@@ -35,11 +38,6 @@ interface OAuthRequest extends Request {
user: OAuthProfileDto;
}
-interface ClientTokens {
- accessToken: string;
- expiresIn: number;
-}
-
@ApiTags('auth')
@Controller('auth')
export class AuthController {
@@ -49,6 +47,7 @@ export class AuthController {
) {}
@Public()
+ @Throttle(AUTH_THROTTLE)
@Post('register')
@HttpCode(HttpStatus.CREATED)
async register(
@@ -59,13 +58,14 @@ export class AuthController {
}
@Public()
+ @Throttle(AUTH_THROTTLE)
@Post('login')
@HttpCode(HttpStatus.OK)
async login(
@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);
}
@@ -76,11 +76,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()
@@ -93,6 +99,7 @@ export class AuthController {
const token = this.readRefreshCookie(req);
await this.service.logout(token);
this.clearRefreshCookie(res);
+ this.clearAccessCookie(res);
}
@Public()
@@ -105,6 +112,7 @@ export class AuthController {
}
@Public()
+ @Throttle(AUTH_THROTTLE)
@Post('resend-verification')
@HttpCode(HttpStatus.ACCEPTED)
async resendVerification(@Body() dto: ResendVerificationDto): Promise {
@@ -112,6 +120,7 @@ export class AuthController {
}
@Public()
+ @Throttle(AUTH_THROTTLE)
@Post('forgot-password')
@HttpCode(HttpStatus.ACCEPTED)
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise {
@@ -119,6 +128,7 @@ export class AuthController {
}
@Public()
+ @Throttle(AUTH_THROTTLE)
@Post('reset-password')
@HttpCode(HttpStatus.NO_CONTENT)
async resetPassword(@Body() dto: ResetPasswordDto): Promise {
@@ -175,16 +185,24 @@ 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());
- } catch {
+ const target = this.config.getOrThrow('auth.oauthSuccessRedirect', {
+ infer: true,
+ });
+ res.redirect(target);
+ } 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,
});
@@ -192,20 +210,33 @@ 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,
- ): { 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(
@@ -224,7 +255,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,
});
}
@@ -233,7 +266,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 {
@@ -251,12 +312,11 @@ export class AuthController {
function ctxFromRequest(req: Request): { userAgent?: string; ip?: string } {
const ua = req.headers['user-agent'];
- // Prefer X-Forwarded-For (set by reverse proxies); fall back to socket IP.
- const forwarded = req.headers['x-forwarded-for'];
- const ip =
- typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : req.ip;
+ // req.ip is set correctly by Express once `trust proxy` is configured in
+ // main.ts. Do NOT read X-Forwarded-For manually β clients can inject fake
+ // entries into that header when they can reach the server directly.
return {
userAgent: typeof ua === 'string' ? ua.slice(0, 255) : undefined,
- ip,
+ ip: req.ip,
};
}
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..46c8234 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) {
@@ -164,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.`,
@@ -251,4 +259,21 @@ 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..d50a85a
--- /dev/null
+++ b/backend/src/modules/auth/services/ip-reputation.service.ts
@@ -0,0 +1,62 @@
+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/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/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;
+ }
+}
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/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/backend/test/jest-e2e.json b/backend/test/jest-e2e.json
new file mode 100644
index 0000000..5216b06
--- /dev/null
+++ b/backend/test/jest-e2e.json
@@ -0,0 +1,16 @@
+{
+ "moduleFileExtensions": ["js", "json", "ts"],
+ "rootDir": ".",
+ "testEnvironment": "node",
+ "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"
+ }
+}
diff --git a/backend/test/unit/modules/auth/auth.controller.spec.ts b/backend/test/unit/modules/auth/auth.controller.spec.ts
index 79a96c5..1c8534f 100644
--- a/backend/test/unit/modules/auth/auth.controller.spec.ts
+++ b/backend/test/unit/modules/auth/auth.controller.spec.ts
@@ -80,6 +80,8 @@ describe('AuthController', () => {
switch (key) {
case 'auth.refreshCookieName':
return 'rt';
+ case 'auth.accessCookieName':
+ return 'at';
case 'nodeEnv':
return 'test';
case 'auth.oauthSuccessRedirect':
@@ -99,12 +101,10 @@ describe('AuthController', () => {
});
describe('register', () => {
- it('delegates with the dto and a request context (forwarded ip, ua)', async () => {
+ it('delegates with the dto and a request context (ip, ua)', async () => {
const req = makeReq({
- headers: {
- 'user-agent': 'jest',
- 'x-forwarded-for': '1.2.3.4, 5.6.7.8',
- },
+ ip: '1.2.3.4',
+ headers: { 'user-agent': 'jest' },
} as Partial);
await controller.register({ email: 'a@b.c' } as never, req);
@@ -117,7 +117,7 @@ describe('AuthController', () => {
});
describe('login', () => {
- it('sets the refresh cookie and returns the client tokens', async () => {
+ it('sets httpOnly cookies and returns only the user', async () => {
const res = makeRes();
const result = await controller.login(
{ email: 'a@b.c', password: 'pw' },
@@ -128,13 +128,9 @@ describe('AuthController', () => {
expect(res.cookie).toHaveBeenCalledWith(
'rt',
'refresh',
- expect.objectContaining({ httpOnly: true, path: '/api/auth' }),
+ expect.objectContaining({ httpOnly: true, path: '/' }),
);
- expect(result).toEqual({
- user: AUTH_RESULT.user,
- accessToken: 'access',
- expiresIn: 900,
- });
+ expect(result).toEqual({ user: AUTH_RESULT.user });
});
});
@@ -166,7 +162,7 @@ describe('AuthController', () => {
await controller.logout(req, res);
expect(service.logout).toHaveBeenCalledWith('tok');
- expect(res.clearCookie).toHaveBeenCalledWith('rt', { path: '/api/auth' });
+ expect(res.clearCookie).toHaveBeenCalledWith('rt', { path: '/' });
});
});
@@ -199,7 +195,7 @@ describe('AuthController', () => {
});
describe('OAuth callback', () => {
- it('redirects to the success URL with tokens on success', async () => {
+ it('redirects to the success URL and sets cookies on success', async () => {
const req = makeReq({ user: { provider: 'google' } } as never);
const res = makeRes();
@@ -207,9 +203,7 @@ describe('AuthController', () => {
expect(res.cookie).toHaveBeenCalled();
const [target] = res.redirect.mock.calls[0] as [string];
- expect(target).toContain('https://app/oauth');
- expect(target).toContain('accessToken=access');
- expect(target).toContain('expiresIn=900');
+ expect(target).toBe('https://app/oauth');
});
it('redirects to the failure URL when sign-in throws', async () => {
diff --git a/backend/test/unit/modules/auth/auth.service.spec.ts b/backend/test/unit/modules/auth/auth.service.spec.ts
index 6b57702..e531d91 100644
--- a/backend/test/unit/modules/auth/auth.service.spec.ts
+++ b/backend/test/unit/modules/auth/auth.service.spec.ts
@@ -9,6 +9,7 @@ import type { User } from '@prisma/client';
import { PrismaService } from '@core/database/prisma.service';
import { AuthService } from '@modules/auth/auth.service';
import { EmailService } from '@modules/auth/services/email.service';
+import { RegionRestrictionService } from '@modules/auth/services/region-restriction.service';
import { TokenService } from '@modules/auth/services/token.service';
import { VerificationService } from '@modules/auth/services/verification.service';
@@ -49,6 +50,7 @@ describe('AuthService', () => {
sendPasswordResetEmail: jest.Mock;
};
let geo: { lookup: jest.Mock };
+ let regionRestriction: { check: jest.Mock };
beforeEach(() => {
prisma = { user: { findUnique: jest.fn(), create: jest.fn() } };
@@ -66,6 +68,7 @@ describe('AuthService', () => {
sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),
};
geo = { lookup: jest.fn().mockReturnValue({ country: 'US', city: 'NYC' }) };
+ regionRestriction = { check: jest.fn().mockResolvedValue(null) };
const config = {
getOrThrow: jest.fn().mockReturnValue('https://app/verify'),
};
@@ -77,6 +80,7 @@ describe('AuthService', () => {
verification as unknown as VerificationService,
email as unknown as EmailService,
geo,
+ regionRestriction as unknown as RegionRestrictionService,
config as unknown as ConfigService,
);
});
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
}
}
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/deployment.md b/docs/deployment.md
index 7a9f4b8..54d0fc7 100644
--- a/docs/deployment.md
+++ b/docs/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/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.
diff --git a/frontend/package.json b/frontend/package.json
index 1e80648..afedda4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -25,9 +25,11 @@
"@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",
+ "jose": "^6.2.3",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
diff --git a/frontend/src/app/(auth)/forgot-password/forgot-password-view.tsx b/frontend/src/app/(auth)/forgot-password/forgot-password-view.tsx
new file mode 100644
index 0000000..e5f0cc5
--- /dev/null
+++ b/frontend/src/app/(auth)/forgot-password/forgot-password-view.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { useState } from "react";
+import { Mail } from "lucide-react";
+import { z } from "zod";
+import { toast } from "sonner";
+
+import { AuthCard, forgotPassword } from "@features/auth";
+import { ApiError } from "@lib/api-client";
+
+const schema = z.object({
+ email: z.email("Enter a valid email."),
+});
+
+type FormValues = z.infer;
+
+const backToSignIn = {
+ text: "Remembered it?",
+ linkText: "Back to sign in",
+ href: "/sign-in",
+} as const;
+
+export const ForgotPasswordView = () => {
+ const [submittedEmail, setSubmittedEmail] = useState(null);
+
+ const handleSubmit = async (values: FormValues) => {
+ try {
+ await forgotPassword(values.email);
+ setSubmittedEmail(values.email);
+ } catch (err) {
+ if (err instanceof ApiError) {
+ toast.error(err.message || "Could not send the reset link.");
+ } else {
+ toast.error("Could not reach the API. Is the backend running?");
+ }
+ }
+ };
+
+ if (submittedEmail) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+};
diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx
index 014735d..d743f69 100644
--- a/frontend/src/app/(auth)/forgot-password/page.tsx
+++ b/frontend/src/app/(auth)/forgot-password/page.tsx
@@ -1,79 +1,16 @@
-"use client";
+import type { Metadata } from "next";
-import { useState } from "react";
-import { Mail } from "lucide-react";
-import { z } from "zod";
-import { toast } from "sonner";
+import { ForgotPasswordView } from "./forgot-password-view";
-import { AuthCard, forgotPassword } from "@features/auth";
-import { ApiError } from "@lib/api-client";
-
-const schema = z.object({
- email: z.email("Enter a valid email."),
-});
-
-type FormValues = z.infer;
-
-const backToSignIn = {
- text: "Remembered it?",
- linkText: "Back to sign in",
- href: "/sign-in",
-} as const;
-
-const Page = () => {
- const [submittedEmail, setSubmittedEmail] = useState(null);
-
- const handleSubmit = async (values: FormValues) => {
- try {
- await forgotPassword(values.email);
- setSubmittedEmail(values.email);
- } catch (err) {
- if (err instanceof ApiError) {
- toast.error(err.message || "Could not send the reset link.");
- } else {
- toast.error("Could not reach the API. Is the backend running?");
- }
- }
- };
-
- if (submittedEmail) {
- return (
-
-
-
-
- );
- }
-
- return (
-
-
-
-
- );
+export const metadata: Metadata = {
+ title: "Reset Your Password",
+ description: "Request a link to reset your Forgeng account password.",
+ robots: {
+ index: false,
+ follow: false,
+ },
};
+const Page = () => ;
+
export default Page;
diff --git a/frontend/src/app/(auth)/sign-in/page.tsx b/frontend/src/app/(auth)/sign-in/page.tsx
index d420eec..edbc29c 100644
--- a/frontend/src/app/(auth)/sign-in/page.tsx
+++ b/frontend/src/app/(auth)/sign-in/page.tsx
@@ -1,93 +1,13 @@
-"use client";
+import type { Metadata } from "next";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { z } from "zod";
-import { toast } from "sonner";
+import { SignInView } from "./sign-in-view";
-import { useCurrentUser } from "@contexts";
-import { AuthCard } from "@features/auth";
-import { ApiError } from "@lib/api-client";
-import { homeForRole } from "@utils/auth";
-
-const schema = z.object({
- email: z.email("Enter a valid email."),
- password: z.string().min(1, "Enter your password."),
-});
-
-type FormValues = z.infer;
-
-const Page = () => {
- const router = useRouter();
- const { login } = useCurrentUser();
-
- const handleSubmit = async (values: FormValues) => {
- try {
- const user = await login(values.email, values.password);
- toast.success(`Signed in as ${user.name ?? user.email}.`);
- router.push(homeForRole(user.role));
- } catch (err) {
- if (err instanceof ApiError) {
- if (err.status === 403) {
- toast.error(
- "Please verify your email first. Check your inbox for the confirmation link.",
- );
- } else if (err.status === 401) {
- toast.error("Invalid email or password.");
- } else {
- toast.error(err.message || "Could not sign in. Please try again.");
- }
- } else {
- toast.error("Could not reach the API. Is the backend running?");
- }
- }
- };
-
- return (
-
-
-
- Forgot password?
-
- ),
- },
- ]}
- />
-
- );
+export const metadata: Metadata = {
+ title: "Sign In",
+ description:
+ "Sign in to your Forgeng account to track your application, view your cohort, and access your apprenticeship dashboard.",
};
+const Page = () => ;
+
export default Page;
diff --git a/frontend/src/app/(auth)/sign-in/sign-in-view.tsx b/frontend/src/app/(auth)/sign-in/sign-in-view.tsx
new file mode 100644
index 0000000..878751a
--- /dev/null
+++ b/frontend/src/app/(auth)/sign-in/sign-in-view.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { z } from "zod";
+import { toast } from "sonner";
+
+import { useCurrentUser } from "@contexts";
+import { AuthCard } from "@features/auth";
+import { ApiError } from "@lib/api-client";
+import { accessRestrictionReason, homeForRole } from "@utils/auth";
+
+const schema = z.object({
+ email: z.email("Enter a valid email."),
+ password: z.string().min(1, "Enter your password."),
+});
+
+type FormValues = z.infer;
+
+export const SignInView = () => {
+ const router = useRouter();
+ const { login } = useCurrentUser();
+
+ const handleSubmit = async (values: FormValues) => {
+ try {
+ const user = await login(values.email, values.password);
+ 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(
+ "Please verify your email first. Check your inbox for the confirmation link.",
+ );
+ } else if (err.status === 401) {
+ toast.error("Invalid email or password.");
+ } else {
+ toast.error(err.message || "Could not sign in. Please try again.");
+ }
+ } else {
+ toast.error("Could not reach the API. Is the backend running?");
+ }
+ }
+ };
+
+ return (
+
+
+
+ Forgot password?
+
+ ),
+ },
+ ]}
+ />
+
+ );
+};
diff --git a/frontend/src/app/(auth)/sign-up/check-email/check-email-view.tsx b/frontend/src/app/(auth)/sign-up/check-email/check-email-view.tsx
new file mode 100644
index 0000000..c233b00
--- /dev/null
+++ b/frontend/src/app/(auth)/sign-up/check-email/check-email-view.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import { Suspense, useState } from "react";
+import { Mail } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@components/ui/button";
+import { AuthCard, resendVerification } from "@features/auth";
+import { ApiError } from "@lib/api-client";
+
+const CheckEmailInner = () => {
+ const params = useSearchParams();
+ const email = params.get("email") ?? "";
+ const [isSending, setIsSending] = useState(false);
+
+ const handleResend = async () => {
+ if (!email) {
+ toast.error("Missing email β go back to sign-up and try again.");
+ return;
+ }
+ setIsSending(true);
+ try {
+ await resendVerification(email);
+ toast.success("If the email is registered, a new link is on its way.");
+ } catch (err) {
+ const message =
+ err instanceof ApiError ? err.message : "Could not resend right now.";
+ toast.error(message);
+ } finally {
+ setIsSending(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export const CheckEmailView = () => (
+
+
+
+);
diff --git a/frontend/src/app/(auth)/sign-up/check-email/page.tsx b/frontend/src/app/(auth)/sign-up/check-email/page.tsx
index ef61bef..c64f027 100644
--- a/frontend/src/app/(auth)/sign-up/check-email/page.tsx
+++ b/frontend/src/app/(auth)/sign-up/check-email/page.tsx
@@ -1,72 +1,15 @@
-"use client";
+import type { Metadata } from "next";
-import { useSearchParams } from "next/navigation";
-import { Suspense, useState } from "react";
-import { Mail } from "lucide-react";
-import { toast } from "sonner";
+import { CheckEmailView } from "./check-email-view";
-import { Button } from "@components/ui/button";
-import { AuthCard, resendVerification } from "@features/auth";
-import { ApiError } from "@lib/api-client";
-
-const CheckEmailInner = () => {
- const params = useSearchParams();
- const email = params.get("email") ?? "";
- const [isSending, setIsSending] = useState(false);
-
- const handleResend = async () => {
- if (!email) {
- toast.error("Missing email β go back to sign-up and try again.");
- return;
- }
- setIsSending(true);
- try {
- await resendVerification(email);
- toast.success("If the email is registered, a new link is on its way.");
- } catch (err) {
- const message =
- err instanceof ApiError ? err.message : "Could not resend right now.";
- toast.error(message);
- } finally {
- setIsSending(false);
- }
- };
-
- return (
-
-
-
-
-
-
- );
+export const metadata: Metadata = {
+ title: "Check Your Email",
+ robots: {
+ index: false,
+ follow: false,
+ },
};
-const Page = () => (
-
-
-
-);
+const Page = () => ;
export default Page;
diff --git a/frontend/src/app/(auth)/sign-up/page.tsx b/frontend/src/app/(auth)/sign-up/page.tsx
index 8b385b7..c2c6768 100644
--- a/frontend/src/app/(auth)/sign-up/page.tsx
+++ b/frontend/src/app/(auth)/sign-up/page.tsx
@@ -1,105 +1,13 @@
-"use client";
+import type { Metadata } from "next";
-import { useRouter } from "next/navigation";
-import { z } from "zod";
-import { toast } from "sonner";
+import { SignUpView } from "./sign-up-view";
-import { useCurrentUser } from "@contexts";
-import { AuthCard } from "@features/auth";
-import { ApiError } from "@lib/api-client";
-
-const schema = z.object({
- firstName: z.string().trim().min(1, "First name is required.").max(80),
- lastName: z.string().trim().min(1, "Last name is required.").max(80),
- email: z.email("Enter a valid email.").max(254),
- password: z
- .string()
- .min(8, "Password must be at least 8 characters.")
- .max(72, "Password is too long.")
- .regex(/[A-Za-z]/, "Password must contain a letter.")
- .regex(/\d/, "Password must contain a digit."),
-});
-
-type FormValues = z.infer;
-
-const Page = () => {
- const router = useRouter();
- const { register } = useCurrentUser();
-
- const handleSubmit = async (values: FormValues) => {
- try {
- const name = `${values.firstName} ${values.lastName}`.trim();
- await register({
- email: values.email,
- password: values.password,
- name,
- });
- toast.success("Account created. Check your email to verify.");
- router.push(
- `/sign-up/check-email?email=${encodeURIComponent(values.email)}`,
- );
- } catch (err) {
- if (err instanceof ApiError && err.status === 409) {
- toast.error("That email is already registered. Try signing in.");
- } else if (err instanceof ApiError) {
- toast.error(err.message || "Could not create account.");
- } else {
- toast.error("Could not reach the API. Is the backend running?");
- }
- }
- };
-
- return (
-
-
-
-
- );
+export const metadata: Metadata = {
+ title: "Create an Account",
+ description:
+ "Apply to Forgeng's mentor-led software engineering apprenticeship. Complete real projects, earn a monthly stipend, and grow into a professional engineer.",
};
+const Page = () => ;
+
export default Page;
diff --git a/frontend/src/app/(auth)/sign-up/sign-up-view.tsx b/frontend/src/app/(auth)/sign-up/sign-up-view.tsx
new file mode 100644
index 0000000..e2cd409
--- /dev/null
+++ b/frontend/src/app/(auth)/sign-up/sign-up-view.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { z } from "zod";
+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),
+ lastName: z.string().trim().min(1, "Last name is required.").max(80),
+ email: z.email("Enter a valid email.").max(254),
+ password: z
+ .string()
+ .min(8, "Password must be at least 8 characters.")
+ .max(72, "Password is too long.")
+ .regex(/[A-Za-z]/, "Password must contain a letter.")
+ .regex(/\d/, "Password must contain a digit."),
+});
+
+type FormValues = z.infer;
+
+export const SignUpView = () => {
+ const router = useRouter();
+ const { register } = useCurrentUser();
+
+ const handleSubmit = async (values: FormValues) => {
+ try {
+ const name = `${values.firstName} ${values.lastName}`.trim();
+ await register({
+ email: values.email,
+ password: values.password,
+ name,
+ });
+ toast.success("Account created. Check your email to verify.");
+ router.push(
+ `/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) {
+ toast.error(err.message || "Could not create account.");
+ } else {
+ toast.error("Could not reach the API. Is the backend running?");
+ }
+ }
+ };
+
+ return (
+
+
+
+
+ );
+};
diff --git a/frontend/src/app/(auth)/unavailable/page.tsx b/frontend/src/app/(auth)/unavailable/page.tsx
new file mode 100644
index 0000000..1df5c5f
--- /dev/null
+++ b/frontend/src/app/(auth)/unavailable/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+
+import { UnavailableView } from "./unavailable-view";
+
+export const metadata: Metadata = {
+ title: "Unavailable",
+ robots: {
+ index: false,
+ follow: false,
+ },
+};
+
+const Page = () => ;
+
+export default Page;
diff --git a/frontend/src/app/(auth)/unavailable/unavailable-view.tsx b/frontend/src/app/(auth)/unavailable/unavailable-view.tsx
new file mode 100644
index 0000000..f427907
--- /dev/null
+++ b/frontend/src/app/(auth)/unavailable/unavailable-view.tsx
@@ -0,0 +1,42 @@
+"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 (
+
+
+
+ );
+};
+
+export const UnavailableView = () => (
+
+
+
+);
diff --git a/frontend/src/app/admin/applications/page.tsx b/frontend/src/app/admin/applications/page.tsx
index 8a0550f..7f97756 100644
--- a/frontend/src/app/admin/applications/page.tsx
+++ b/frontend/src/app/admin/applications/page.tsx
@@ -2,117 +2,38 @@
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 { ListPageLayout } from "@components/shared";
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@components/ui/select";
-import {
- List,
- StatusTabs,
+ ApplicationList,
+ ApplicationStatusTabs,
+ 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 ? (
-
- ) : (
-
+ header={{
+ title: "Applications",
+ description: "Review and manage applicants through the pipeline.",
+ }}
+ useData={useApplications}
+ filter={filter}
+ filterComponent={}
+ listComponent={({ items }) => (
+ router.push(`/admin/applications/${app.id}`)}
/>
)}
-
- {total > 0 && (
-
-
- Per page
-
-
-
-
-
- Page {page} of {totalPages}
-
-
-
-
- )}
-
+ emptyMessage="No applications in this category."
+ loadingMessage="Loading applicationsβ¦"
+ />
);
};
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 = () => {
)}
- (
-
- {children}
-
+ {children}
);
export default Layout;
diff --git a/frontend/src/app/admin/reviews/page.tsx b/frontend/src/app/admin/reviews/page.tsx
index 597aa66..652faba 100644
--- a/frontend/src/app/admin/reviews/page.tsx
+++ b/frontend/src/app/admin/reviews/page.tsx
@@ -11,7 +11,7 @@ import { EmptyState, PageContainer, PageHeader } from "@components/shared";
import { SUBMISSION_STATUS_FILTER_TABS } from "@constants/submissions";
import {
ReviewSheet,
- StatusBadge,
+ SubmissionStatusBadge,
useSubmissions,
type SubmissionStatusFilter,
} from "@features/submissions";
@@ -69,7 +69,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 = () => {
))}
-
{stat.payment.amount} {stat.payment.currency} paid
- {stat.payment.txLink && (
+ {stat.payment.txLink && isSafeHref(stat.payment.txLink) && (
{
const [page, setPage] = useState(1);
@@ -48,7 +48,7 @@ const Page = () => {
) : (
{users.map((user) => (
-
+
))}
)}
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/layout.tsx b/frontend/src/app/apply/layout.tsx
new file mode 100644
index 0000000..19bf98e
--- /dev/null
+++ b/frontend/src/app/apply/layout.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+
+export const metadata: Metadata = {
+ title: "Apply",
+ description:
+ "Apply to Forgeng's mentor-led software engineering apprenticeship. Free to apply, no CS degree required, monthly stipend included.",
+};
+
+const ApplyLayout = ({ children }: { children: ReactNode }) => children;
+
+export default ApplyLayout;
diff --git a/frontend/src/app/apply/status/page.tsx b/frontend/src/app/apply/status/page.tsx
index cec294a..7955c44 100644
--- a/frontend/src/app/apply/status/page.tsx
+++ b/frontend/src/app/apply/status/page.tsx
@@ -1,118 +1,15 @@
-"use client";
+import type { Metadata } from "next";
-import { useEffect } from "react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
+import { ApplicationStatusView } from "./status-view";
-import { Logo } from "@components/brand/logo";
-import { Button } from "@components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@components/ui/card";
-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";
-
-const STATUS_COPY: Record =
- {
- pending: {
- title: "Application received",
- body: "Thanks for applying! Your application is in the queue. We review every application personally and aim to get back to you within a week.",
- },
- accepted: {
- title: "You're in! π",
- body: "Congratulations β you've been accepted. Sign in again to access your student portal and get started.",
- },
- rejected: {
- title: "Application decision",
- body: "Thank you for your interest in Forgeng. We're unable to offer you a place this time, but we'd welcome a future application.",
- },
- };
-
-const StatusInner = () => {
- const router = useRouter();
- const { refreshUser } = useCurrentUser();
- const { data, isLoading } = useAsyncResource(() => getMyApplication(), []);
-
- const goToPortal = async () => {
- // Acceptance promotes the account to `student` server-side; sync the local
- // session so the student route guard lets them through.
- await refreshUser();
- router.push("/student");
- };
-
- useEffect(() => {
- // No application on record β send them to the apply form.
- if (!isLoading && data === null) router.replace("/apply");
- }, [isLoading, data, router]);
-
- if (isLoading || data == null) {
- return (
-
-
-
-
-
-
-
-
-
- );
- }
-
- const copy = STATUS_COPY[data.status];
- const submitted = new Date(data.createdAt).toLocaleDateString(undefined, {
- year: "numeric",
- month: "long",
- day: "numeric",
- });
-
- return (
-
-
-
-
-
- Forgeng
-
-
-
- {copy.title}
-
-
-
{copy.body}
-
-
-
-
- Submitted on {submitted}
-
- {data.status === "accepted" ? (
-
- ) : (
-
- )}
-
-
-
- );
+export const metadata: Metadata = {
+ title: "Application Status",
+ robots: {
+ index: false,
+ follow: false,
+ },
};
-const Page = () => (
-
-
-
-);
+const Page = () => ;
export default Page;
diff --git a/frontend/src/app/apply/status/status-view.tsx b/frontend/src/app/apply/status/status-view.tsx
new file mode 100644
index 0000000..11a79b5
--- /dev/null
+++ b/frontend/src/app/apply/status/status-view.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { useEffect } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+
+import { Logo } from "@components/brand/logo";
+import { Button } from "@components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@components/ui/card";
+import { Skeleton } from "@components/ui/skeleton";
+import { useCurrentUser } from "@contexts";
+import { getMyApplication, ApplicationStatusBadge } from "@features/applications";
+import { useAsyncResource } from "@hooks/use-async-resource";
+import type { ApplicationStatus } from "@types";
+
+const STATUS_COPY: Record =
+ {
+ pending: {
+ title: "Application received",
+ body: "Thanks for applying! Your application is in the queue. We review every application personally and aim to get back to you within a week.",
+ },
+ accepted: {
+ title: "You're in! π",
+ body: "Congratulations β you've been accepted. Sign in again to access your student portal and get started.",
+ },
+ rejected: {
+ title: "Application decision",
+ body: "Thank you for your interest in Forgeng. We're unable to offer you a place this time, but we'd welcome a future application.",
+ },
+ };
+
+export const ApplicationStatusView = () => {
+ const router = useRouter();
+ const { refreshUser } = useCurrentUser();
+ const { data, isLoading } = useAsyncResource(() => getMyApplication(), []);
+
+ const goToPortal = async () => {
+ // Acceptance promotes the account to `student` server-side; sync the local
+ // session so the student route guard lets them through.
+ await refreshUser();
+ router.push("/student");
+ };
+
+ useEffect(() => {
+ // No application on record β send them to the apply form.
+ if (!isLoading && data === null) router.replace("/apply");
+ }, [isLoading, data, router]);
+
+ if (isLoading || data == null) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const copy = STATUS_COPY[data.status];
+ const submitted = new Date(data.createdAt).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+
+ return (
+
+
+
+
+
+ Forgeng
+
+
+
+
+
+ Submitted on {submitted}
+
+ {data.status === "accepted" ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
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/auth/layout.tsx b/frontend/src/app/auth/layout.tsx
new file mode 100644
index 0000000..c19a15f
--- /dev/null
+++ b/frontend/src/app/auth/layout.tsx
@@ -0,0 +1,13 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+
+export const metadata: Metadata = {
+ robots: {
+ index: false,
+ follow: false,
+ },
+};
+
+const AuthFlowLayout = ({ children }: { children: ReactNode }) => children;
+
+export default AuthFlowLayout;
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 13b64ff..aa9ab44 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -21,26 +21,55 @@ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
- title: "Forgeng",
+ title: {
+ default: "Forgeng β Software Engineering Apprenticeship",
+ template: "%s | Forgeng",
+ },
description:
- "A rigorous, mentor-led apprenticeship for aspiring software engineers β apply, join a cohort, complete real projects, and grow.",
+ "A rigorous, mentor-led apprenticeship for aspiring software engineers in the US and Canada. Complete real projects, earn a monthly stipend, get expert code reviews, and grow into a professional engineer β no CS degree required.",
+ keywords: [
+ "software engineering apprenticeship",
+ "learn software engineering",
+ "mentorship for developers",
+ "coding apprenticeship",
+ "earn while you learn programming",
+ "software engineering cohort",
+ "developer training program",
+ "no CS degree required",
+ "real projects new developers",
+ ],
+ authors: [{ name: "Forgeng" }],
+ creator: "Forgeng",
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ "max-image-preview": "large",
+ "max-snippet": -1,
+ },
+ },
icons: {
icon: [{ url: "/icon.png", sizes: "256x256", type: "image/png" }],
shortcut: "/favicon.ico",
apple: [{ url: "/apple-icon.png", sizes: "180x180", type: "image/png" }],
},
openGraph: {
- title: "Forgeng",
+ title: "Forgeng β Software Engineering Apprenticeship",
description:
- "A rigorous, mentor-led apprenticeship for aspiring software engineers.",
- images: ["/logo.png"],
+ "A rigorous, mentor-led apprenticeship where you earn a monthly stipend while learning software engineering. Complete real projects, get expert code reviews, no CS degree required.",
+ url: "/",
+ siteName: "Forgeng",
+ images: [{ url: "/logo.png", alt: "Forgeng" }],
+ locale: "en_US",
type: "website",
},
twitter: {
- card: "summary",
- title: "Forgeng",
+ card: "summary_large_image",
+ title: "Forgeng β Software Engineering Apprenticeship",
description:
- "A rigorous, mentor-led apprenticeship for aspiring software engineers.",
+ "A rigorous, mentor-led apprenticeship where you earn a monthly stipend while learning software engineering.",
images: ["/logo.png"],
},
};
diff --git a/frontend/src/app/privacy/page.tsx b/frontend/src/app/privacy/page.tsx
index 19f915e..00c8ac5 100644
--- a/frontend/src/app/privacy/page.tsx
+++ b/frontend/src/app/privacy/page.tsx
@@ -3,9 +3,9 @@ import type { Metadata } from "next";
import { LegalPage, LegalSection } from "@components/legal";
export const metadata: Metadata = {
- title: "Privacy Policy β Forgeng",
+ title: "Privacy Policy",
description:
- "How Forgeng collects, uses, and protects your personal information.",
+ "How Forgeng collects, uses, and protects your personal information across our website, applications, and apprenticeship programs.",
};
const PrivacyPage = () => (
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/layout.tsx b/frontend/src/app/student/layout.tsx
index 8464a94..8c07b1d 100644
--- a/frontend/src/app/student/layout.tsx
+++ b/frontend/src/app/student/layout.tsx
@@ -1,13 +1,19 @@
+import type { Metadata } from "next";
+
import { SidebarLayout } from "@components/layout/sidebar-layout";
-import { RoleGuard } from "@lib/auth";
import { SelectedCohortProvider } from "@providers";
+export const metadata: Metadata = {
+ robots: {
+ index: false,
+ follow: false,
+ },
+};
+
const Layout = ({ children }: { children: React.ReactNode }) => (
-
-
- {children}
-
-
+
+ {children}
+
);
export default Layout;
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 ? (
-
+
) : (