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 +
+
+
+ {copy.title} + +
+ {copy.body} +
+
+ +

+ 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 ? ( - + ) : ( + + Page {page} of {totalPages} + + +
+ + )} + + ); +}; 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/constants/applications/wizard.ts b/frontend/src/constants/applications/wizard.ts index 16f5d4b..bf3b72e 100644 --- a/frontend/src/constants/applications/wizard.ts +++ b/frontend/src/constants/applications/wizard.ts @@ -29,7 +29,7 @@ export const APPLICATION_WIZARD_COPY = { }, videoIntro: { title: "Video Introduction", - hint: "You just need to record your video for about 20–30 seconds. No need to make it long β€” just briefly describe yourself.", + hint: "Please record a short video introducing yourself, about 20–30 seconds long. Just say hello and share a little about who you are β€” there's no need to make it long or formal.", allowCameraLabel: "Allow Camera", recordLabel: "Start Recording", stopLabel: "Stop", 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 }) => ( +
+ {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)} + +
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/features/applications/components/application-detail-page.tsx b/frontend/src/features/applications/components/application-detail-page.tsx index 3c112e5..d829d96 100644 --- a/frontend/src/features/applications/components/application-detail-page.tsx +++ b/frontend/src/features/applications/components/application-detail-page.tsx @@ -1,96 +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 type { ApplicationStatus } from "@types"; +import { useAsyncResource } from "@hooks/use-async-resource"; import { getApplication } from "../api"; -import { useUpdateApplicationStatus } from "../hooks"; -import { StatusBadge } from "./status-badge"; - -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", -}; +import { ApplicationStatusBadge } from "./status-badge"; +import { ApplicationContent } from "./application-content"; +import { ApplicationReviewPanel } from "./application-review-panel"; +import { ApplicationWalletsCard } from "./application-wallets-card"; -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 (
@@ -101,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 */}
{

{application.firstName} {application.lastName}

- +

{application.email} Β· Applied{" "} @@ -127,7 +52,6 @@ export const ApplicationDetailPage = ({ id }: Props) => {

- {/* Video */} {application.videoUrl && ( @@ -145,157 +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 }) => ( - - ), - )} -
-
- )} - {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

- -
- -
-

Reviewer Note

-