From ea5bbd80079f09cf9cac88abba01d827888f68b3 Mon Sep 17 00:00:00 2001 From: Khadija Date: Fri, 25 Sep 2026 03:20:18 +0100 Subject: [PATCH] feat(auth): harden registration and protected sessions Add server-verified protected-route sessions, secure cookie-backed refresh/logout, locale persistence, and accessible registration validation and strength feedback. Closes #1305 Closes #1306 Closes #1307 Closes #1308 --- .github/workflows/ci.yml | 4 +- backend/.env.example | 12 +- backend/src/auth/auth-cookie.spec.ts | 66 +++ backend/src/auth/auth-cookie.ts | 107 +++++ backend/src/auth/auth.controller.spec.ts | 153 ++++++- backend/src/auth/auth.controller.ts | 166 ++++++-- backend/src/auth/auth.service.spec.ts | 42 +- backend/src/auth/auth.service.ts | 96 ++++- backend/src/auth/dto/refresh-auth.dto.ts | 7 +- .../src/auth/dto/register-auth.dto.spec.ts | 4 +- backend/src/auth/dto/register-auth.dto.ts | 16 +- .../src/auth/strategies/github.strategy.ts | 2 +- .../src/auth/strategies/google.strategy.ts | 2 +- .../src/auth/strategies/jwt.strategy.spec.ts | 7 + backend/src/auth/strategies/jwt.strategy.ts | 22 +- backend/src/common/cors.config.spec.ts | 10 +- backend/src/common/cors.config.ts | 93 ++++- backend/src/config/config.validation.spec.ts | 4 +- backend/src/config/config.validation.ts | 1 + backend/src/mail/mail.service.ts | 4 +- backend/src/main.ts | 2 +- backend/test/app.e2e-spec.ts | 19 +- frontend/__tests__/ibinola.test.ts | 2 +- .../(protected)/admin/providers/page.tsx | 4 +- .../[locale]/(protected)/admin/users/page.tsx | 4 +- .../app/[locale]/(protected)/profile/page.tsx | 4 +- .../(protected)/settings/data/page.tsx | 43 +- frontend/app/[locale]/2fa/setup/page.tsx | 50 +-- frontend/app/[locale]/2fa/verify/page.tsx | 33 +- .../app/[locale]/auth/oauth/callback/page.tsx | 62 +++ .../auth/refresh/SessionRefreshClient.tsx | 58 +++ frontend/app/[locale]/auth/refresh/page.tsx | 10 + .../app/[locale]/forgot-password/page.tsx | 20 +- frontend/app/[locale]/login/LoginForm.tsx | 24 +- frontend/app/[locale]/page.tsx | 23 +- frontend/app/[locale]/register/page.tsx | 5 + frontend/app/[locale]/reset-password/page.tsx | 24 +- frontend/app/[locale]/verify-email/page.tsx | 48 +-- frontend/app/register/page.tsx | 5 + frontend/app/robots.ts | 2 +- frontend/app/schemas/register.schema.ts | 22 +- frontend/components/LanguageSwitcher.tsx | 57 +-- frontend/components/RegisterForm.tsx | 383 ++++++++++++++++++ frontend/e2e/dialog.spec.ts | 5 +- frontend/e2e/example.spec.ts | 5 +- frontend/i18n/routing.ts | 3 + frontend/lib/api-client.ts | 256 +++++++++--- frontend/lib/auth-cookie.ts | 2 + frontend/lib/auth-session.ts | 46 ++- frontend/lib/oauth-redirect.ts | 28 ++ frontend/lib/schemas/auth.ts | 10 +- frontend/messages/en.json | 63 +++ frontend/messages/es.json | 63 +++ frontend/messages/fr.json | 63 +++ frontend/middleware.ts | 266 ++++++++++-- frontend/test-utils/LoginForm.test.tsx | 18 +- frontend/test-utils/RegisterPage.test.tsx | 134 ++++++ frontend/test-utils/api-client.test.ts | 90 +++- .../test-utils/language-switcher.test.tsx | 27 +- frontend/test-utils/middleware.test.ts | 156 +++++++ frontend/test-utils/mocks/handlers.ts | 16 +- 61 files changed, 2513 insertions(+), 460 deletions(-) create mode 100644 backend/src/auth/auth-cookie.spec.ts create mode 100644 backend/src/auth/auth-cookie.ts create mode 100644 frontend/app/[locale]/auth/oauth/callback/page.tsx create mode 100644 frontend/app/[locale]/auth/refresh/SessionRefreshClient.tsx create mode 100644 frontend/app/[locale]/auth/refresh/page.tsx create mode 100644 frontend/app/[locale]/register/page.tsx create mode 100644 frontend/components/RegisterForm.tsx create mode 100644 frontend/lib/auth-cookie.ts create mode 100644 frontend/lib/oauth-redirect.ts create mode 100644 frontend/test-utils/RegisterPage.test.tsx create mode 100644 frontend/test-utils/middleware.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63e77d6b..8730bedd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,10 +234,10 @@ jobs: JWT_REFRESH_EXPIRATION: 7d GOOGLE_CLIENT_ID: ci-test-google-client-id GOOGLE_CLIENT_SECRET: ci-test-google-client-secret - GOOGLE_CALLBACK_URL: http://localhost:3001/api/auth/google/callback + GOOGLE_CALLBACK_URL: http://localhost:3001/api/v1/auth/google/callback GITHUB_CLIENT_ID: ci-test-github-client-id GITHUB_CLIENT_SECRET: ci-test-github-client-secret - GITHUB_CALLBACK_URL: http://localhost:3001/api/auth/github/callback + GITHUB_CALLBACK_URL: http://localhost:3001/api/v1/auth/github/callback MAIL_HOST: localhost MAIL_PORT: '1025' MAIL_USER: test@smalda.com diff --git a/backend/.env.example b/backend/.env.example index 06137a8d..eed8938e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,22 +10,24 @@ JWT_SECRET=your-super-secret-jwt-key-change-this-in-production JWT_EXPIRATION=15m JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production JWT_REFRESH_EXPIRATION=7d +# Required when frontend and API use different hostnames; use a shared parent domain. +AUTH_COOKIE_DOMAIN= # Application Configuration -APP_PORT=6004 -APP_URL=http://localhost:6004 -FRONTEND_URL=http://localhost:3001 +APP_PORT=3001 +APP_URL=http://localhost:3001 +FRONTEND_URL=http://localhost:3000 LOG_LEVEL=info # Google OAuth Configuration GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret -GOOGLE_CALLBACK_URL=http://localhost:6004/api/auth/google/callback +GOOGLE_CALLBACK_URL=http://localhost:3001/api/v1/auth/google/callback # GitHub OAuth Configuration GITHUB_CLIENT_ID=your-github-client-id GITHUB_CLIENT_SECRET=your-github-client-secret -GITHUB_CALLBACK_URL=http://localhost:6004/api/auth/github/callback +GITHUB_CALLBACK_URL=http://localhost:3001/api/v1/auth/github/callback # Email Configuration (Nodemailer) MAIL_HOST=smtp.gmail.com diff --git a/backend/src/auth/auth-cookie.spec.ts b/backend/src/auth/auth-cookie.spec.ts new file mode 100644 index 00000000..aed4aadf --- /dev/null +++ b/backend/src/auth/auth-cookie.spec.ts @@ -0,0 +1,66 @@ +import { ConfigService } from '@nestjs/config'; +import { + ACCESS_COOKIE_NAME, + REFRESH_COOKIE_NAME, + clearAuthCookies, + getCookieValue, + setAuthCookies, +} from './auth-cookie'; + +describe('auth cookies', () => { + it('sets secure HttpOnly cookies for production origins', () => { + const config = { + get: jest.fn((key: string) => { + if (key === 'NODE_ENV') return 'production'; + if (key === 'AUTH_COOKIE_DOMAIN') return 'example.com'; + if (key === 'JWT_EXPIRATION') return '15m'; + if (key === 'JWT_REFRESH_EXPIRATION') return '7d'; + return undefined; + }), + } as unknown as ConfigService; + const response = { cookie: jest.fn(), clearCookie: jest.fn() }; + + setAuthCookies( + response as never, + { access_token: 'access', refresh_token: 'refresh' }, + config, + ); + + expect(response.cookie).toHaveBeenCalledWith( + ACCESS_COOKIE_NAME, + 'access', + expect.objectContaining({ + httpOnly: true, + secure: true, + sameSite: 'none', + domain: 'example.com', + path: '/', + maxAge: 900000, + }), + ); + expect(response.cookie).toHaveBeenCalledWith( + REFRESH_COOKIE_NAME, + 'refresh', + expect.objectContaining({ httpOnly: true, maxAge: 604800000 }), + ); + }); + + it('parses encoded cookie values and clears both cookies', () => { + const config = { + get: jest.fn((key: string) => + key === 'NODE_ENV' ? 'development' : undefined, + ), + } as unknown as ConfigService; + const response = { cookie: jest.fn(), clearCookie: jest.fn() }; + + expect( + getCookieValue( + 'other=value; access_token=access%2Bvalue; refresh_token=refresh', + ACCESS_COOKIE_NAME, + ), + ).toBe('access+value'); + + clearAuthCookies(response as never, config); + expect(response.clearCookie).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/src/auth/auth-cookie.ts b/backend/src/auth/auth-cookie.ts new file mode 100644 index 00000000..2cf3db13 --- /dev/null +++ b/backend/src/auth/auth-cookie.ts @@ -0,0 +1,107 @@ +import { ConfigService } from '@nestjs/config'; +import { Response } from 'express'; + +export const ACCESS_COOKIE_NAME = 'access_token'; +export const REFRESH_COOKIE_NAME = 'refresh_token'; + +export interface AuthTokens { + access_token: string; + refresh_token: string; +} + +interface AuthCookieOptions { + httpOnly: true; + secure: boolean; + sameSite: 'lax' | 'none'; + path: string; + domain?: string; +} + +function getCookieOptions(configService: ConfigService): AuthCookieOptions { + const nodeEnv = configService.get('NODE_ENV') || 'development'; + const secure = nodeEnv === 'production' || nodeEnv === 'staging'; + const domain = configService.get('AUTH_COOKIE_DOMAIN')?.trim(); + + return { + httpOnly: true, + secure, + sameSite: secure ? 'none' : 'lax', + path: '/', + ...(domain ? { domain } : {}), + }; +} + +function durationMilliseconds( + value: string | undefined, + fallbackSeconds: number, +): number { + const normalized = value?.trim(); + if (!normalized) return fallbackSeconds * 1000; + + if (/^\d+$/.test(normalized)) return Number(normalized) * 1000; + + const match = /^(\d+)([smhd])$/.exec(normalized); + if (!match) return fallbackSeconds * 1000; + + const amount = Number(match[1]); + const multipliers = { s: 1, m: 60, h: 3600, d: 86400 } as const; + return amount * multipliers[match[2] as keyof typeof multipliers] * 1000; +} + +export function setAuthCookies( + response: Response | undefined, + tokens: AuthTokens, + configService: ConfigService, +): void { + if (!response) return; + + const options = getCookieOptions(configService); + response.cookie(ACCESS_COOKIE_NAME, tokens.access_token, { + ...options, + maxAge: durationMilliseconds(configService.get('JWT_EXPIRATION'), 900), + }); + response.cookie(REFRESH_COOKIE_NAME, tokens.refresh_token, { + ...options, + maxAge: durationMilliseconds( + configService.get('JWT_REFRESH_EXPIRATION'), + 604800, + ), + }); +} + +export function clearAuthCookies( + response: Response | undefined, + configService: ConfigService, +): void { + if (!response) return; + + const options = getCookieOptions(configService); + response.clearCookie(ACCESS_COOKIE_NAME, options); + response.clearCookie(REFRESH_COOKIE_NAME, options); +} + +export function getCookieValue( + cookieHeader: string | undefined, + name: string, +): string | undefined { + if (!cookieHeader) return undefined; + + for (const part of cookieHeader.split(';')) { + const separator = part.indexOf('='); + if (separator < 0) continue; + + const key = part.slice(0, separator).trim(); + if (key !== name) continue; + + const value = part.slice(separator + 1).trim(); + if (!value) return undefined; + + try { + return decodeURIComponent(value); + } catch { + return value; + } + } + + return undefined; +} diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts index d1152db8..9ecb83c1 100644 --- a/backend/src/auth/auth.controller.spec.ts +++ b/backend/src/auth/auth.controller.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; @@ -11,9 +12,21 @@ describe('AuthController', () => { const mockAuthService = { register: jest.fn(), login: jest.fn(), + refreshToken: jest.fn(), + logout: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn((key: string) => { + if (key === 'NODE_ENV') return 'test'; + if (key === 'JWT_EXPIRATION') return '15m'; + if (key === 'JWT_REFRESH_EXPIRATION') return '7d'; + return undefined; + }), }; beforeEach(async () => { + jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], providers: [ @@ -21,6 +34,10 @@ describe('AuthController', () => { provide: AuthService, useValue: mockAuthService, }, + { + provide: ConfigService, + useValue: mockConfigService, + }, ], }) .overrideGuard(JwtAuthGuard) @@ -37,33 +54,129 @@ describe('AuthController', () => { expect(controller).toBeDefined(); }); - describe('register', () => { - it('should call authService.register with the provided dto', async () => { - const registerDto = { + it('sets HttpOnly cookies when registering', async () => { + const registerDto = { + email: 'test@example.com', + password: 'Password123!', + fullName: 'Test User', + }; + const tokens = { + access_token: 'jwt-token', + refresh_token: 'refresh-token', + }; + const response = { cookie: jest.fn(), clearCookie: jest.fn() }; + mockAuthService.register.mockResolvedValue(tokens); + + const result = await controller.register(registerDto, response as never); + + expect(authService.register).toHaveBeenCalledWith(registerDto); + expect(result).toEqual(tokens); + expect(response.cookie).toHaveBeenCalledWith( + 'access_token', + tokens.access_token, + expect.objectContaining({ + httpOnly: true, + secure: false, + sameSite: 'lax', + path: '/', + }), + ); + expect(response.cookie).toHaveBeenCalledWith( + 'refresh_token', + tokens.refresh_token, + expect.objectContaining({ httpOnly: true }), + ); + }); + + it('sets HttpOnly cookies when logging in', async () => { + const loginDto = { email: 'test@example.com', password: 'Password123!' }; + const tokens = { + access_token: 'jwt-token', + refresh_token: 'refresh-token', + }; + const response = { cookie: jest.fn(), clearCookie: jest.fn() }; + mockAuthService.login.mockResolvedValue(tokens); + + const result = await controller.login(loginDto, response as never); + + expect(authService.login).toHaveBeenCalledWith(loginDto); + expect(result).toEqual(tokens); + expect(response.cookie).toHaveBeenCalledTimes(2); + }); + + it('returns only safe identity fields from /me', () => { + const result = controller.me({ + user: { + id: 'user-1', email: 'test@example.com', - password: 'password123', fullName: 'Test User', - }; - const token = { access_token: 'jwt-token' }; - mockAuthService.register.mockResolvedValue(token); - - const result = await controller.register(registerDto); + role: 'user', + isVerified: true, + preferredLanguage: 'en', + twoFactorEnabled: false, + passwordHash: 'secret-hash', + }, + } as never); - expect(authService.register).toHaveBeenCalledWith(registerDto); - expect(result).toEqual(token); + expect(result).toEqual({ + id: 'user-1', + email: 'test@example.com', + fullName: 'Test User', + role: 'user', + isVerified: true, + preferredLanguage: 'en', + twoFactorEnabled: false, }); }); - describe('login', () => { - it('should call authService.login with the provided dto', async () => { - const loginDto = { email: 'test@example.com', password: 'password123' }; - const token = { access_token: 'jwt-token', refresh_token: 'refresh-token' }; - mockAuthService.login.mockResolvedValue(token); + it('clears both cookies on logout', async () => { + const response = { cookie: jest.fn(), clearCookie: jest.fn() }; + const request = { + headers: { + authorization: 'Bearer bearer-token', + cookie: 'access_token=cookie-token; refresh_token=refresh-token', + origin: 'http://localhost:3000', + }, + }; - const result = await controller.login(loginDto); + await controller.logout(request as never, response as never); - expect(authService.login).toHaveBeenCalledWith(loginDto); - expect(result).toEqual(token); - }); + expect(authService.logout).toHaveBeenCalledWith( + ['bearer-token', 'cookie-token'], + 'refresh-token', + ); + expect(response.clearCookie).toHaveBeenCalledTimes(2); + }); + + it('clears cookies even if token revocation fails', async () => { + const response = { cookie: jest.fn(), clearCookie: jest.fn() }; + const request = { + headers: { + authorization: 'Bearer bearer-token', + cookie: 'access_token=cookie-token; refresh_token=refresh-token', + origin: 'http://localhost:3000', + }, + }; + mockAuthService.logout.mockRejectedValueOnce(new Error('revoke failed')); + + await expect( + controller.logout(request as never, response as never), + ).rejects.toThrow('revoke failed'); + expect(response.clearCookie).toHaveBeenCalledTimes(2); + }); + + it('rejects cookie-authenticated refresh from an untrusted origin', async () => { + const response = { cookie: jest.fn(), clearCookie: jest.fn() }; + const request = { + headers: { + cookie: 'refresh_token=refresh-token', + origin: 'https://untrusted.example', + }, + }; + + await expect( + controller.refresh({}, request as never, response as never), + ).rejects.toThrow('Request origin is not allowed'); + expect(authService.refreshToken).not.toHaveBeenCalled(); }); }); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index dfa80d44..088bcc35 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -3,10 +3,12 @@ Controller, Post, Get, + Header, Req, Res, UseGuards, BadRequestException, + ForbiddenException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AuthGuard } from '@nestjs/passport'; @@ -18,8 +20,20 @@ import { AuthService } from './auth.service'; import { RegisterAuthDto } from './dto/register-auth.dto'; import { LoginAuthDto } from './dto/login-auth.dto'; import { RefreshAuthDto } from './dto/refresh-auth.dto'; -import { getFrontendUrl } from '../common/cors.config'; +import { + getFrontendUrl, + isAllowedFrontendOrigin, +} from '../common/cors.config'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { + ACCESS_COOKIE_NAME, + AuthTokens, + REFRESH_COOKIE_NAME, + clearAuthCookies, + getCookieValue, + setAuthCookies, +} from './auth-cookie'; +import { User } from '../users/entities/user.entity'; @Controller('auth') export class AuthController { @@ -29,31 +43,92 @@ export class AuthController { ) {} @Post('register') - register(@Body() dto: RegisterAuthDto) { - return this.authService.register(dto); + async register( + @Body() dto: RegisterAuthDto, + @Res({ passthrough: true }) response?: Response, + @Req() request?: Request, + ) { + this.assertTrustedBrowserRequest(request); + const tokens = await this.authService.register(dto); + setAuthCookies(response, tokens, this.configService); + return tokens; } @Post('login') - login(@Body() dto: LoginAuthDto) { - return this.authService.login(dto); + async login( + @Body() dto: LoginAuthDto, + @Res({ passthrough: true }) response?: Response, + @Req() request?: Request, + ) { + this.assertTrustedBrowserRequest(request); + const tokens = await this.authService.login(dto); + setAuthCookies(response, tokens, this.configService); + return tokens; } @Post('refresh') - refresh(@Body() dto: RefreshAuthDto) { - return this.authService.refreshToken(dto); + async refresh( + @Body() dto: RefreshAuthDto, + @Req() request: Request, + @Res({ passthrough: true }) response?: Response, + ) { + this.assertTrustedBrowserRequest(request); + const cookieRefreshToken = getCookieValue( + request.headers.cookie, + REFRESH_COOKIE_NAME, + ); + const tokens = await this.authService.refreshToken({ + refreshToken: cookieRefreshToken ?? dto?.refreshToken, + }); + setAuthCookies(response, tokens, this.configService); + return tokens; } @Post('logout') - @UseGuards(JwtAuthGuard) - async logout(@Req() req: Request) { - const authHeader = req.headers.authorization; - if (authHeader) { - const token = authHeader.replace('Bearer ', ''); - await this.authService.logout(token); + async logout( + @Req() request: Request, + @Res({ passthrough: true }) response?: Response, + ) { + this.assertTrustedBrowserRequest(request); + const bearerToken = this.getBearerToken(request.headers.authorization); + const cookieAccessToken = getCookieValue( + request.headers.cookie, + ACCESS_COOKIE_NAME, + ); + const accessTokens = [ + ...new Set([bearerToken, cookieAccessToken].filter(Boolean)), + ] as string[]; + const refreshToken = getCookieValue( + request.headers.cookie, + REFRESH_COOKIE_NAME, + ); + + try { + await this.authService.logout(accessTokens, refreshToken); + } finally { + clearAuthCookies(response, this.configService); } return { message: 'Logged out successfully' }; } + @Get('me') + @Header('Cache-Control', 'no-store') + @UseGuards(JwtAuthGuard) + me(@Req() request: Request & { user?: User }) { + const user = request.user; + if (!user) throw new BadRequestException('Authenticated user is required'); + + return { + id: user.id, + email: user.email, + fullName: user.fullName, + role: user.role, + isVerified: user.isVerified, + preferredLanguage: user.preferredLanguage, + twoFactorEnabled: user.twoFactorEnabled, + }; + } + @Get('google') @UseGuards(AuthGuard('google')) googleAuth() { @@ -80,12 +155,12 @@ export class AuthController { profile?.name?.familyName, ]); - const { access_token } = await this.authService.handleOAuthLogin( + const tokens = await this.authService.handleOAuthLogin( email, fullName || email, ); - return this.redirectWithToken(access_token, res); + return this.redirectWithCookies(tokens, res); } @Get('github') @@ -109,24 +184,69 @@ export class AuthController { } const fullName = - this.buildFullName([profile?.displayName, profile?.username]) || - identifier; + this.buildFullName([ + profile?.displayName, + profile?.username, + ]) || identifier; - const { access_token } = await this.authService.handleOAuthLogin( + const tokens = await this.authService.handleOAuthLogin( identifier, fullName, ); - return this.redirectWithToken(access_token, res); + return this.redirectWithCookies(tokens, res); } - private redirectWithToken(accessToken: string, res: Response) { - const frontendUrl = getFrontendUrl(this.configService); - const redirectUrl = new URL(frontendUrl); - redirectUrl.searchParams.set('token', accessToken); + private redirectWithCookies(tokens: AuthTokens, res: Response) { + setAuthCookies(res, tokens, this.configService); + const redirectUrl = new URL(getFrontendUrl(this.configService)); + const basePath = redirectUrl.pathname.replace(/\/+$/, ""); + redirectUrl.pathname = `${basePath}/auth/oauth/callback`; + redirectUrl.search = ''; + redirectUrl.hash = new URLSearchParams({ + access_token: tokens.access_token, + }).toString(); return res.redirect(redirectUrl.toString()); } + private assertTrustedBrowserRequest(request?: Request): void { + if (!request) return; + + if (request.headers['sec-fetch-site'] === 'cross-site') { + throw new ForbiddenException('Request origin is not allowed'); + } + + const origin = request.headers.origin; + if (origin) { + if (!isAllowedFrontendOrigin(this.configService, origin)) { + throw new ForbiddenException('Request origin is not allowed'); + } + return; + } + + const referer = request.headers.referer; + if (!referer) { + if (request.headers.cookie) { + throw new ForbiddenException('Request origin is not allowed'); + } + return; + } + + try { + if (!isAllowedFrontendOrigin(this.configService, new URL(referer).origin)) { + throw new ForbiddenException('Request origin is not allowed'); + } + } catch (error) { + if (error instanceof ForbiddenException) throw error; + throw new ForbiddenException('Request origin is not allowed'); + } + } + + private getBearerToken(header: string | undefined): string | undefined { + const match = header?.match(/^Bearer\s+(.+)$/i); + return match?.[1]?.trim() || undefined; + } + private buildFullName(parts: (string | undefined)[]) { return parts .map((part) => part?.trim()) diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 7fff21be..7d4b66f9 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -107,8 +107,8 @@ describe('AuthService', () => { }); describe('logout()', () => { - it('should add token to blacklist', async () => { - mockJwtService.decode.mockReturnValue({ + it('should add a verified token to the blacklist', async () => { + mockJwtService.verifyAsync.mockResolvedValueOnce({ sub: 'user-1', exp: Math.floor(Date.now() / 1000) + 3600, }); @@ -117,12 +117,44 @@ describe('AuthService', () => { expect(service.isTokenBlacklisted('test-token')).toBe(true); }); - it('should handle malformed tokens gracefully', async () => { - mockJwtService.decode.mockImplementation(() => { - throw new Error('invalid'); + it('revokes both distinct verified access credentials', async () => { + mockJwtService.verifyAsync.mockResolvedValue({ + sub: 'user-1', + exp: Math.floor(Date.now() / 1000) + 3600, }); + await service.logout( + ['access-token-a', 'access-token-b'], + 'refresh-token', + ); + + expect(service.isTokenBlacklisted('access-token-a')).toBe(true); + expect(service.isTokenBlacklisted('access-token-b')).toBe(true); + }); + + it('continues revoking after a malformed access credential', async () => { + mockJwtService.verifyAsync.mockImplementation(async (token: string) => { + if (token === 'bad-access-token') throw new Error('invalid'); + return { + sub: 'user-1', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + }); + + await service.logout( + ['bad-access-token', 'good-access-token'], + 'refresh-token', + ); + + expect(service.isTokenBlacklisted('good-access-token')).toBe(true); + expect(service.isTokenBlacklisted('bad-access-token')).toBe(false); + }); + + it('should handle malformed tokens gracefully', async () => { + mockJwtService.verifyAsync.mockRejectedValueOnce(new Error('invalid')); + await expect(service.logout('bad-token')).resolves.toBeUndefined(); + expect(service.isTokenBlacklisted('bad-token')).toBe(false); }); }); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index c5b9ab7c..c9235d63 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -18,6 +18,8 @@ import { User, UserRole } from '../users/entities/user.entity'; @Injectable() export class AuthService { private readonly tokenBlacklist = new Set(); + private readonly refreshTokenBlacklist = new Set(); + private readonly maxBlacklistEntries = 10000; constructor( private readonly usersService: UsersService, @@ -41,7 +43,8 @@ export class AuthService { }); const access_token = await this.generateAccessToken(user); - return { access_token }; + const refresh_token = await this.generateRefreshToken(user); + return { access_token, refresh_token }; } async login(dto: LoginAuthDto) { @@ -76,15 +79,20 @@ export class AuthService { } const access_token = await this.generateAccessToken(user); - return { access_token }; + const refresh_token = await this.generateRefreshToken(user); + return { access_token, refresh_token }; } async refreshToken(dto: RefreshAuthDto) { - const refreshToken = dto.refreshToken?.trim(); + const refreshToken = dto?.refreshToken?.trim(); if (!refreshToken) { throw new BadRequestException('Refresh token is required'); } + if (this.refreshTokenBlacklist.has(refreshToken)) { + throw new UnauthorizedException('Invalid refresh token'); + } + try { const payload = await this.jwtService.verifyAsync( refreshToken, @@ -98,25 +106,31 @@ export class AuthService { throw new UnauthorizedException('Invalid refresh token'); } + await this.revokeRefreshToken(refreshToken, payload); const access_token = await this.generateAccessToken(user); - return { access_token }; + const refresh_token = await this.generateRefreshToken(user); + return { access_token, refresh_token }; } catch { throw new UnauthorizedException('Invalid refresh token'); } } - async logout(accessToken: string): Promise { - try { - const payload = this.jwtService.decode(accessToken); - if (payload?.exp) { - const ttl = payload.exp - Math.floor(Date.now() / 1000); - if (ttl > 0) { - this.tokenBlacklist.add(accessToken); - setTimeout(() => this.tokenBlacklist.delete(accessToken), ttl * 1000); - } - } - } catch { - // Token is malformed, nothing to blacklist + async logout( + accessTokenOrTokens?: string | string[], + refreshToken?: string, + ): Promise { + const accessTokens = Array.isArray(accessTokenOrTokens) + ? accessTokenOrTokens + : accessTokenOrTokens + ? [accessTokenOrTokens] + : []; + const distinctAccessTokens = [...new Set(accessTokens.filter(Boolean))]; + + await Promise.allSettled( + distinctAccessTokens.map((token) => this.blacklistAccessToken(token)), + ); + if (refreshToken) { + await Promise.allSettled([this.revokeRefreshToken(refreshToken)]); } } @@ -124,6 +138,56 @@ export class AuthService { return this.tokenBlacklist.has(token); } + private async blacklistAccessToken(token: string): Promise { + try { + const payload = await this.jwtService.verifyAsync(token); + this.rememberToken(this.tokenBlacklist, token, this.getPayloadTtl(payload)); + } catch { + return; + } + } + + private async revokeRefreshToken( + token: string, + verifiedPayload?: JwtPayload, + ): Promise { + let payload = verifiedPayload; + if (!payload) { + try { + payload = await this.jwtService.verifyAsync(token, { + secret: this.getRefreshSecret(), + }); + } catch { + return; + } + } + if (!payload) return; + + this.rememberToken( + this.refreshTokenBlacklist, + token, + this.getPayloadTtl(payload), + ); + } + + private rememberToken( + blacklist: Set, + token: string, + ttl: number | null, + ): void { + if (ttl === null || ttl <= 0 || blacklist.has(token)) return; + + if (blacklist.size >= this.maxBlacklistEntries) return; + + blacklist.add(token); + setTimeout(() => blacklist.delete(token), ttl * 1000); + } + + private getPayloadTtl(payload: JwtPayload): number | null { + if (!payload.exp) return null; + return payload.exp - Math.floor(Date.now() / 1000); + } + private async validateCredentials(email: string, password: string) { const user = await this.usersService.findByEmail(email); if (!user) { diff --git a/backend/src/auth/dto/refresh-auth.dto.ts b/backend/src/auth/dto/refresh-auth.dto.ts index 91a3107f..d52ed6b4 100644 --- a/backend/src/auth/dto/refresh-auth.dto.ts +++ b/backend/src/auth/dto/refresh-auth.dto.ts @@ -1,6 +1,7 @@ -import { IsNotEmpty } from 'class-validator'; +import { IsOptional, IsString } from 'class-validator'; export class RefreshAuthDto { - @IsNotEmpty() - refreshToken: string; + @IsOptional() + @IsString() + refreshToken?: string; } diff --git a/backend/src/auth/dto/register-auth.dto.spec.ts b/backend/src/auth/dto/register-auth.dto.spec.ts index df063424..d192de52 100644 --- a/backend/src/auth/dto/register-auth.dto.spec.ts +++ b/backend/src/auth/dto/register-auth.dto.spec.ts @@ -6,7 +6,7 @@ describe('RegisterAuthDto', () => { it('should trim and lowercase email', async () => { const dto = plainToInstance(RegisterAuthDto, { email: ' Test@EXAMPLE.COM ', - password: 'password123', + password: 'Password123!', fullName: ' John Doe ', }); const errors = await validate(dto); @@ -18,7 +18,7 @@ describe('RegisterAuthDto', () => { it('should reject invalid email', async () => { const dto = plainToInstance(RegisterAuthDto, { email: 'not-an-email', - password: 'password123', + password: 'Password123!', fullName: 'John Doe', }); const errors = await validate(dto); diff --git a/backend/src/auth/dto/register-auth.dto.ts b/backend/src/auth/dto/register-auth.dto.ts index cb8fee98..754fdc60 100644 --- a/backend/src/auth/dto/register-auth.dto.ts +++ b/backend/src/auth/dto/register-auth.dto.ts @@ -1,4 +1,10 @@ -import { IsEmail, IsNotEmpty, MinLength } from 'class-validator'; +import { + IsEmail, + IsNotEmpty, + IsString, + Matches, + MinLength, +} from 'class-validator'; import { Transform } from 'class-transformer'; export class RegisterAuthDto { @@ -6,8 +12,12 @@ export class RegisterAuthDto { @Transform(({ value }) => value?.trim().toLowerCase()) email: string; - @IsNotEmpty() - @MinLength(6) + @IsString() + @MinLength(8) + @Matches(/[A-Z]/, { message: 'passwordUppercase' }) + @Matches(/[a-z]/, { message: 'passwordLowercase' }) + @Matches(/[0-9]/, { message: 'passwordNumber' }) + @Matches(/[^A-Za-z0-9]/, { message: 'passwordSpecial' }) password: string; @IsNotEmpty() diff --git a/backend/src/auth/strategies/github.strategy.ts b/backend/src/auth/strategies/github.strategy.ts index 3ee665b0..6f686f32 100644 --- a/backend/src/auth/strategies/github.strategy.ts +++ b/backend/src/auth/strategies/github.strategy.ts @@ -18,7 +18,7 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github') { clientSecret, callbackURL: configService.get('GITHUB_CALLBACK_URL') || - `${configService.get('APP_URL') || 'http://localhost:6004'}/api/auth/github/callback`, + `${configService.get('APP_URL') || 'http://localhost:3001'}/api/v1/auth/github/callback`, scope: ['user:email'], }); } diff --git a/backend/src/auth/strategies/google.strategy.ts b/backend/src/auth/strategies/google.strategy.ts index 49bf060f..5f09d3d3 100644 --- a/backend/src/auth/strategies/google.strategy.ts +++ b/backend/src/auth/strategies/google.strategy.ts @@ -18,7 +18,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { clientSecret, callbackURL: configService.get('GOOGLE_CALLBACK_URL') || - `${configService.get('APP_URL') || 'http://localhost:6004'}/api/auth/google/callback`, + `${configService.get('APP_URL') || 'http://localhost:3001'}/api/v1/auth/google/callback`, scope: ['email', 'profile'], }); } diff --git a/backend/src/auth/strategies/jwt.strategy.spec.ts b/backend/src/auth/strategies/jwt.strategy.spec.ts index 6c92d37b..a3f23e24 100644 --- a/backend/src/auth/strategies/jwt.strategy.spec.ts +++ b/backend/src/auth/strategies/jwt.strategy.spec.ts @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { User } from '../../users/entities/user.entity'; +import { AuthService } from '../auth.service'; describe('JwtStrategy', () => { let strategy: JwtStrategy; @@ -25,6 +26,12 @@ describe('JwtStrategy', () => { get: jest.fn().mockReturnValue('test-secret'), }, }, + { + provide: AuthService, + useValue: { + isTokenBlacklisted: jest.fn().mockReturnValue(false), + }, + }, ], }).compile(); diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index 405667c5..6bda1dcb 100644 --- a/backend/src/auth/strategies/jwt.strategy.ts +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -2,10 +2,18 @@ import { PassportStrategy } from '@nestjs/passport'; import { ConfigService } from '@nestjs/config'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Request } from 'express'; import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { UsersService } from '../../users/users.service'; import { AuthService } from '../auth.service'; +import { ACCESS_COOKIE_NAME, getCookieValue } from '../auth-cookie'; + +const accessTokenExtractor = ExtractJwt.fromExtractors([ + ExtractJwt.fromAuthHeaderAsBearerToken(), + (request: Request) => + getCookieValue(request.headers.cookie, ACCESS_COOKIE_NAME), +]); @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -15,20 +23,26 @@ export class JwtStrategy extends PassportStrategy(Strategy) { configService: ConfigService, ) { super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: accessTokenExtractor, secretOrKey: configService.get('JWT_SECRET'), ignoreExpiration: false, passReqToCallback: true, }); } - async validate(request: any, payload: JwtPayload) { - const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); + async validate(request: any, payload?: JwtPayload) { + const tokenRequest = payload ? request : undefined; + const tokenPayload = payload ?? (request as JwtPayload); + const token = + tokenRequest && tokenRequest.headers + ? (accessTokenExtractor(tokenRequest as Request) as string | null) + : null; + if (token && this.authService.isTokenBlacklisted(token)) { throw new UnauthorizedException('Token has been revoked'); } - const user = await this.usersService.findById(payload.sub); + const user = await this.usersService.findById(tokenPayload.sub); if (!user) { throw new UnauthorizedException('User not found'); } diff --git a/backend/src/common/cors.config.spec.ts b/backend/src/common/cors.config.spec.ts index 20893886..c755e7cc 100644 --- a/backend/src/common/cors.config.spec.ts +++ b/backend/src/common/cors.config.spec.ts @@ -56,12 +56,12 @@ describe('buildCorsOptions', () => { it('should allow the configured origin in development', (done) => { const config = createConfigService({ NODE_ENV: 'development', - FRONTEND_URL: 'http://localhost:3001', + FRONTEND_URL: 'http://localhost:3000', }); const { corsOptions } = buildCorsOptions(config); (corsOptions.origin as Function)( - 'http://localhost:3001', + 'http://localhost:3000', (err: Error | null, allow?: boolean) => { expect(err).toBeNull(); expect(allow).toBe(true); @@ -116,7 +116,7 @@ describe('buildCorsOptions', () => { const { corsOptions } = buildCorsOptions(config); (corsOptions.origin as Function)( - 'http://localhost:3001', + 'http://localhost:3000', (err: Error | null, allow?: boolean) => { expect(err).toBeNull(); expect(allow).toBe(true); @@ -128,7 +128,7 @@ describe('buildCorsOptions', () => { it('should restrict methods and headers', () => { const config = createConfigService({ NODE_ENV: 'development', - FRONTEND_URL: 'http://localhost:3001', + FRONTEND_URL: 'http://localhost:3000', }); const { corsOptions } = buildCorsOptions(config); @@ -156,7 +156,7 @@ describe('getFrontendUrl', () => { it('returns fallback when nothing is configured', () => { const config = createConfigService({}); - expect(getFrontendUrl(config)).toBe('http://localhost:3001'); + expect(getFrontendUrl(config)).toBe('http://localhost:3000'); }); }); diff --git a/backend/src/common/cors.config.ts b/backend/src/common/cors.config.ts index aba5284c..63d42c4c 100644 --- a/backend/src/common/cors.config.ts +++ b/backend/src/common/cors.config.ts @@ -1,7 +1,7 @@ import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; import { ConfigService } from '@nestjs/config'; -const DEFAULT_DEV_ORIGIN = 'http://localhost:3001'; +const DEFAULT_DEV_ORIGIN = 'http://localhost:3000'; const ALLOWED_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']; @@ -29,6 +29,7 @@ export function buildCorsOptions( ): { isProduction: boolean; corsOptions: CorsOptions } { const nodeEnv = configService.get('NODE_ENV') || 'development'; const isProduction = nodeEnv === 'production'; + const usesSecureCookies = nodeEnv === 'production' || nodeEnv === 'staging'; const frontendUrl = configService.get('FRONTEND_URL'); @@ -37,6 +38,60 @@ export function buildCorsOptions( 'FRONTEND_URL must be explicitly set in production. Credentialed CORS cannot fall back to a development origin.', ); } + if (usesSecureCookies && !frontendUrl) { + throw new Error( + 'FRONTEND_URL must be explicitly set when secure cookies are enabled.', + ); + } + + const appUrl = configService.get('APP_URL'); + if (usesSecureCookies && !appUrl) { + throw new Error( + 'APP_URL must be explicitly set when secure cookies are enabled.', + ); + } + const frontendOrigins = [...parseAllowlist(frontendUrl, false)]; + const appProtocol = appUrl ? new URL(appUrl).protocol : undefined; + if (usesSecureCookies && appProtocol !== 'https:') { + throw new Error('APP_URL must use HTTPS when secure cookies are enabled.'); + } + if ( + usesSecureCookies && + frontendOrigins.some((origin) => new URL(origin).protocol !== 'https:') + ) { + throw new Error( + 'FRONTEND_URL must use HTTPS when secure cookies are enabled.', + ); + } + const cookieDomain = normalizeCookieDomain( + configService.get('AUTH_COOKIE_DOMAIN'), + ); + const appHostname = appUrl + ? normalizeHostname(new URL(appUrl).hostname) + : undefined; + const frontendHostnames = frontendOrigins.map( + (origin) => normalizeHostname(new URL(origin).hostname), + ); + const hasDifferentFrontendHostname = frontendHostnames.some( + (hostname) => hostname !== appHostname, + ); + if (usesSecureCookies && appHostname && hasDifferentFrontendHostname) { + if (!cookieDomain) { + throw new Error( + 'AUTH_COOKIE_DOMAIN must be set when the frontend and API use different hostnames.', + ); + } + if ( + !domainCoversHost(cookieDomain, appHostname) || + frontendHostnames.some( + (hostname) => !domainCoversHost(cookieDomain, hostname), + ) + ) { + throw new Error( + 'AUTH_COOKIE_DOMAIN must cover both the frontend and API hostnames.', + ); + } + } const allowlist = parseAllowlist(frontendUrl, !isProduction); @@ -65,6 +120,20 @@ export function buildCorsOptions( return { isProduction, corsOptions }; } +function normalizeHostname(hostname: string): string { + return hostname.trim().toLowerCase().replace(/\.$/, ''); +} + +function normalizeCookieDomain(domain: string | undefined): string | undefined { + const normalized = domain?.trim().replace(/^\.+/, '').toLowerCase(); + return normalized || undefined; +} + +function domainCoversHost(domain: string, hostname: string): boolean { + if (!domain.includes('.')) return false; + return hostname === domain || hostname.endsWith(`.${domain}`); +} + function parseAllowlist( frontendUrl: string | undefined, allowDevFallback: boolean, @@ -76,7 +145,13 @@ function parseAllowlist( .split(',') .map((o) => o.trim()) .filter((o) => o.length > 0) - .forEach((o) => allowlist.add(o)); + .forEach((o) => { + try { + allowlist.add(new URL(o).origin); + } catch { + allowlist.add(o); + } + }); } if (allowDevFallback && allowlist.size === 0) { @@ -86,9 +161,21 @@ function parseAllowlist( return allowlist; } +export function isAllowedFrontendOrigin( + configService: ConfigService, + origin: string, +): boolean { + const nodeEnv = configService.get('NODE_ENV') || 'development'; + const allowlist = parseAllowlist( + configService.get('FRONTEND_URL'), + nodeEnv !== 'production', + ); + return allowlist.has(origin); +} + export function getFrontendUrl( configService: ConfigService, - fallback = 'http://localhost:3001', + fallback = 'http://localhost:3000', ): string { const configured = configService.get('FRONTEND_URL'); if (configured) { diff --git a/backend/src/config/config.validation.spec.ts b/backend/src/config/config.validation.spec.ts index 6ee49bb9..c8354a65 100644 --- a/backend/src/config/config.validation.spec.ts +++ b/backend/src/config/config.validation.spec.ts @@ -26,10 +26,10 @@ function validEnv(): Record { JWT_REFRESH_EXPIRATION: '7d', GOOGLE_CLIENT_ID: 'google-id', GOOGLE_CLIENT_SECRET: 'google-secret', - GOOGLE_CALLBACK_URL: 'http://localhost:3001/auth/google/callback', + GOOGLE_CALLBACK_URL: 'http://localhost:3001/api/v1/auth/google/callback', GITHUB_CLIENT_ID: 'github-id', GITHUB_CLIENT_SECRET: 'github-secret', - GITHUB_CALLBACK_URL: 'http://localhost:3001/auth/github/callback', + GITHUB_CALLBACK_URL: 'http://localhost:3001/api/v1/auth/github/callback', MAIL_HOST: 'smtp.example.com', MAIL_PORT: '587', MAIL_USER: 'user@example.com', diff --git a/backend/src/config/config.validation.ts b/backend/src/config/config.validation.ts index e613723d..20d55fa4 100644 --- a/backend/src/config/config.validation.ts +++ b/backend/src/config/config.validation.ts @@ -93,6 +93,7 @@ export const ConfigValidationSchema = Joi.object({ otherwise: Joi.string().min(32).required(), }), JWT_REFRESH_EXPIRATION: Joi.string().default('7d'), + AUTH_COOKIE_DOMAIN: Joi.string().allow('').optional(), // ── OAuth ────────────────────────────────────────────────────────────────── GOOGLE_CLIENT_ID: Joi.string().when('NODE_ENV', { diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts index 4e668f3f..ba89f0c5 100644 --- a/backend/src/mail/mail.service.ts +++ b/backend/src/mail/mail.service.ts @@ -64,8 +64,8 @@ export class MailService { } async sendVerificationEmail(to: string, token: string): Promise { - const appUrl = this.configService.get('APP_URL') || 'http://localhost:6004'; - const verificationUrl = `${appUrl}/api/auth/verify-email?token=${token}`; + const appUrl = this.configService.get('APP_URL') || 'http://localhost:3001'; + const verificationUrl = `${appUrl}/api/v1/auth/verify-email?token=${encodeURIComponent(token)}`; await this.sendMail({ to, diff --git a/backend/src/main.ts b/backend/src/main.ts index 148877b9..c1651fd1 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -95,7 +95,7 @@ async function bootstrap() { }, }); - const port = configService.get('APP_PORT') || 6004; + const port = configService.get('APP_PORT') || 3001; await app.listen(port); console.log(`Application is running on: http://localhost:${port}`); diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 46912ca6..10b6a9c4 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -157,27 +157,28 @@ describe('App (e2e)', () => { describe('Auth flow', () => { it('should register a new user and return an access_token', async () => { - const res = await register('alice@test.com', 'password123', 'Alice'); + const res = await register('alice@test.com', 'Password123!', 'Alice'); expect(res.status).toBe(201); expect(res.body).toHaveProperty('access_token'); + expect(res.body).toHaveProperty('refresh_token'); }); it('should login with valid credentials and return both tokens', async () => { - await register('bob@test.com', 'secret456', 'Bob'); - const res = await login('bob@test.com', 'secret456'); + await register('bob@test.com', 'Secret456!', 'Bob'); + const res = await login('bob@test.com', 'Secret456!'); expect(res.status).toBe(200); expect(res.body).toHaveProperty('access_token'); expect(res.body).toHaveProperty('refresh_token'); }); it('should reject duplicate email registration with 409', async () => { - await register('dup@test.com', 'password123', 'First'); - const res = await register('dup@test.com', 'password123', 'Second'); + await register('dup@test.com', 'Password123!', 'First'); + const res = await register('dup@test.com', 'Password123!', 'Second'); expect(res.status).toBe(409); }); it('should reject login with wrong password with 401', async () => { - await register('wrong@test.com', 'correct', 'User'); + await register('wrong@test.com', 'Correct123!', 'User'); const res = await login('wrong@test.com', 'incorrect'); expect(res.status).toBe(401); }); @@ -193,7 +194,7 @@ describe('App (e2e)', () => { beforeEach(async () => { const reg = await register( 'docuser@test.com', - 'password123', + 'Password123!', 'Doc User', ); token = reg.body.access_token; @@ -285,10 +286,10 @@ describe('App (e2e)', () => { let docIdA: string; beforeEach(async () => { - const regA = await register('usera@test.com', 'password123', 'User A'); + const regA = await register('usera@test.com', 'Password123!', 'User A'); tokenA = regA.body.access_token; - const regB = await register('userb@test.com', 'password123', 'User B'); + const regB = await register('userb@test.com', 'Password123!', 'User B'); tokenB = regB.body.access_token; // User A uploads a document diff --git a/frontend/__tests__/ibinola.test.ts b/frontend/__tests__/ibinola.test.ts index d13227b5..8e2126b7 100644 --- a/frontend/__tests__/ibinola.test.ts +++ b/frontend/__tests__/ibinola.test.ts @@ -7,7 +7,7 @@ describe('ibinola Frontend Features (FE-99, FE-98, FE-97, FE-96)', () => { const rules = robots().rules; const rule = Array.isArray(rules) ? rules[0] : rules; expect(rule.disallow).toContain('/verify/'); - expect(rule.disallow).toContain('/dashboard/'); + expect(rule.disallow).toContain('/'); }); it('sitemap exports root marketing URL', () => { diff --git a/frontend/app/[locale]/(protected)/admin/providers/page.tsx b/frontend/app/[locale]/(protected)/admin/providers/page.tsx index 5cad2961..8c9d3a93 100644 --- a/frontend/app/[locale]/(protected)/admin/providers/page.tsx +++ b/frontend/app/[locale]/(protected)/admin/providers/page.tsx @@ -272,7 +272,7 @@ export default function AdminProvidersPage() { try { const payload = JSON.parse(atob(token.split(".")[1])); if (payload?.role !== "admin") { - router.replace("/dashboard"); + router.replace("/"); } } catch { router.replace("/login"); @@ -288,7 +288,7 @@ export default function AdminProvidersPage() { headers: getAuthHeaders(), }); if (res.status === 403) { - router.replace("/dashboard"); + router.replace("/"); return; } if (!res.ok) throw new Error(`Failed to load provider stats: ${res.status}`); diff --git a/frontend/app/[locale]/(protected)/admin/users/page.tsx b/frontend/app/[locale]/(protected)/admin/users/page.tsx index faf72619..adb4f0ab 100644 --- a/frontend/app/[locale]/(protected)/admin/users/page.tsx +++ b/frontend/app/[locale]/(protected)/admin/users/page.tsx @@ -156,7 +156,7 @@ export default function AdminUsersPage() { if (!token) { router.replace("/login"); return; } try { const payload = JSON.parse(atob(token.split(".")[1])); - if (payload?.role !== "admin") { router.replace("/dashboard"); } + if (payload?.role !== "admin") { router.replace("/"); } } catch { router.replace("/login"); } @@ -179,7 +179,7 @@ export default function AdminUsersPage() { const res = await fetch(`${API_BASE}/api/users?${params}`, { headers: getAuthHeaders(), }); - if (res.status === 403) { router.replace("/dashboard"); return; } + if (res.status === 403) { router.replace("/"); return; } if (!res.ok) throw new Error(`Failed to load users: ${res.status}`); const json: PaginatedUsers = await res.json(); setUsers(json.data); diff --git a/frontend/app/[locale]/(protected)/profile/page.tsx b/frontend/app/[locale]/(protected)/profile/page.tsx index 339294da..90db8dda 100644 --- a/frontend/app/[locale]/(protected)/profile/page.tsx +++ b/frontend/app/[locale]/(protected)/profile/page.tsx @@ -34,10 +34,8 @@ async function fetchCurrentUser(): Promise { } async function patchUser(changes: Partial): Promise { - // Replace with real fetch: - // await fetch('/api/users/me', { method: 'PATCH', body: JSON.stringify(changes) }) await new Promise((r) => setTimeout(r, 700)); - console.log("PATCH /api/users/me", changes); + void changes; } function getChangedFields( diff --git a/frontend/app/[locale]/(protected)/settings/data/page.tsx b/frontend/app/[locale]/(protected)/settings/data/page.tsx index dc3c869d..bc23c4a5 100644 --- a/frontend/app/[locale]/(protected)/settings/data/page.tsx +++ b/frontend/app/[locale]/(protected)/settings/data/page.tsx @@ -1,23 +1,19 @@ "use client"; import React, { useEffect, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; +import { useRouter } from "@/i18n/navigation"; +import { + clearSession, + request, + requestBlob, +} from "@/lib/api-client"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function getAuthHeaders(): HeadersInit { - const token = - typeof window !== "undefined" ? localStorage.getItem("auth-token") : null; - return token ? { Authorization: `Bearer ${token}` } : {}; -} - -function logout() { - localStorage.removeItem("auth-token"); - document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; +async function logout(): Promise { + return clearSession(); } const COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -184,11 +180,7 @@ export default function SettingsDataPage() { setIsExporting(true); setExportError(null); try { - const res = await fetch(`${API_BASE}/api/users/me/export`, { - headers: getAuthHeaders(), - }); - if (!res.ok) throw new Error(`Export failed: ${res.status}`); - const blob = await res.blob(); + const blob = await requestBlob("/api/v1/users/me/export"); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -215,23 +207,14 @@ export default function SettingsDataPage() { setIsDeleting(true); setDeleteError(null); try { - const res = await fetch(`${API_BASE}/api/users/me/data`, { + await request("/api/v1/users/me/data", { method: "DELETE", - headers: { - "Content-Type": "application/json", - ...getAuthHeaders(), - }, - body: JSON.stringify({ password }), + body: { password }, }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error( - body?.message ?? `Deletion failed: ${res.status}` - ); + if (!(await logout())) { + throw new Error("Sign out failed. Please try again."); } - - logout(); router.push("/?deleted=true"); } catch (err: unknown) { setDeleteError( diff --git a/frontend/app/[locale]/2fa/setup/page.tsx b/frontend/app/[locale]/2fa/setup/page.tsx index b2871eca..c270cbab 100644 --- a/frontend/app/[locale]/2fa/setup/page.tsx +++ b/frontend/app/[locale]/2fa/setup/page.tsx @@ -1,7 +1,8 @@ "use client"; import React, { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter } from "@/i18n/navigation"; +import { request } from "@/lib/api-client"; export default function TwoFactorSetupPage() { const router = useRouter(); @@ -16,15 +17,13 @@ export default function TwoFactorSetupPage() { useEffect(() => { async function init2FA() { try { - const response = await fetch("/api/auth/2fa/setup", { method: "POST" }); - if (response.ok) { - const data = await response.json(); - setOtpUri(data.otpauthUrl || `otpauth://totp/SMALDA?secret=${data.secret}`); - setSecret(data.secret || "JBSWY3DPEHPK3PXP"); - setStep("scan"); - } else { - setError("Failed to initialize 2FA setup. Please try again."); - } + const data = await request<{ + otpauthUrl?: string; + secret?: string; + }>("/api/v1/auth/2fa/setup", { method: "POST" }); + setOtpUri(data.otpauthUrl || `otpauth://totp/SMALDA?secret=${data.secret}`); + setSecret(data.secret || "JBSWY3DPEHPK3PXP"); + setStep("scan"); } catch { setError("Network error initializing 2FA."); } @@ -43,21 +42,24 @@ export default function TwoFactorSetupPage() { setError(""); try { - const response = await fetch("/api/auth/2fa/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: totpCode, secret }), - }); - - if (response.ok) { - const data = await response.json(); - setBackupCodes(data.backupCodes || ["CODE-1234-5678", "CODE-8765-4321", "CODE-9988-7766"]); - setStep("confirmed"); - } else { - setError("Invalid verification code. Please check your authenticator app."); - } + const data = await request<{ backupCodes?: string[] }>( + "/api/v1/auth/2fa/verify", + { + method: "POST", + anonymous: true, + body: { code: totpCode, secret }, + }, + ); + setBackupCodes( + data.backupCodes || [ + "CODE-1234-5678", + "CODE-8765-4321", + "CODE-9988-7766", + ], + ); + setStep("confirmed"); } catch { - setError("Network error verifying code."); + setError("Invalid verification code. Please check your authenticator app."); } finally { setVerifying(false); } diff --git a/frontend/app/[locale]/2fa/verify/page.tsx b/frontend/app/[locale]/2fa/verify/page.tsx index c2600d60..4b5d27f8 100644 --- a/frontend/app/[locale]/2fa/verify/page.tsx +++ b/frontend/app/[locale]/2fa/verify/page.tsx @@ -1,7 +1,9 @@ "use client"; import React, { useState, useEffect } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; +import { useRouter } from "@/i18n/navigation"; +import { request } from "@/lib/api-client"; export default function TwoFactorVerifyPage() { const router = useRouter(); @@ -36,31 +38,26 @@ export default function TwoFactorVerifyPage() { setError(""); try { - const response = await fetch("/api/auth/2fa/challenge", { + await request("/api/v1/auth/2fa/challenge", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + anonymous: true, + body: { challengeToken, code, isBackupCode, - }), + }, }); + router.push("/"); + } catch { + const nextAttempts = failedAttempts + 1; + setFailedAttempts(nextAttempts); - if (response.ok) { - router.push("/dashboard"); + if (nextAttempts >= 3) { + setLockoutSeconds(15); + setError("Too many failed attempts. Please wait 15 seconds before trying again."); } else { - const nextAttempts = failedAttempts + 1; - setFailedAttempts(nextAttempts); - - if (nextAttempts >= 3) { - setLockoutSeconds(15); - setError("Too many failed attempts. Please wait 15 seconds before trying again."); - } else { - setError(`Invalid 2FA code. Attempt ${nextAttempts} of 3 before temporary lockout.`); - } + setError(`Invalid 2FA code. Attempt ${nextAttempts} of 3 before temporary lockout.`); } - } catch { - setError("Network error during verification."); } finally { setLoading(false); } diff --git a/frontend/app/[locale]/auth/oauth/callback/page.tsx b/frontend/app/[locale]/auth/oauth/callback/page.tsx new file mode 100644 index 00000000..7ae39846 --- /dev/null +++ b/frontend/app/[locale]/auth/oauth/callback/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Link, useRouter } from "@/i18n/navigation"; +import { storeSession, resolvePostLoginPath } from "@/lib/auth-session"; +import { consumeOAuthRedirect } from "@/lib/oauth-redirect"; + +export default function OAuthCallbackPage() { + const t = useTranslations(); + const router = useRouter(); + const started = useRef(false); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (started.current) return; + started.current = true; + + const params = new URLSearchParams(window.location.hash.slice(1)); + const accessToken = params.get("access_token"); + if (!accessToken || accessToken.length > 4096) { + setFailed(true); + return; + } + + storeSession({ access_token: accessToken }); + window.history.replaceState( + {}, + "", + `${window.location.pathname}${window.location.search}`, + ); + const target = resolvePostLoginPath(consumeOAuthRedirect()); + router.replace( + target === "/auth/oauth/callback" || target === "/auth/refresh" + ? "/" + : target, + ); + }, [router]); + + return ( +
+
+

+ {failed ? t("auth.oauthCallback.errorTitle") : t("auth.oauthCallback.loading")} +

+ {failed ? ( + <> +

+ {t("auth.oauthCallback.errorDescription")} +

+ + {t("auth.oauthCallback.signIn")} + + + ) : null} +
+
+ ); +} diff --git a/frontend/app/[locale]/auth/refresh/SessionRefreshClient.tsx b/frontend/app/[locale]/auth/refresh/SessionRefreshClient.tsx new file mode 100644 index 00000000..117ae70c --- /dev/null +++ b/frontend/app/[locale]/auth/refresh/SessionRefreshClient.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useRouter } from "@/i18n/navigation"; +import { + clearSession, + isDefinitiveRefreshError, + refreshSession, +} from "@/lib/api-client"; +import { resolvePostLoginPath } from "@/lib/auth-session"; + +export default function SessionRefreshClient() { + const t = useTranslations(); + const router = useRouter(); + const searchParams = useSearchParams(); + const started = useRef(false); + const [failed, setFailed] = useState(false); + const target = resolvePostLoginPath(searchParams.get("redirect")); + + useEffect(() => { + if (started.current) return; + started.current = true; + + void (async () => { + try { + await refreshSession(); + router.replace(target === "/auth/refresh" ? "/" : target); + } catch (error) { + if (isDefinitiveRefreshError(error)) { + await clearSession(); + const params = new URLSearchParams({ redirect: target }); + router.replace(`/login?${params.toString()}`); + return; + } + setFailed(true); + } + })(); + }, [router, target]); + + return ( +
+
+

+ {failed + ? t("auth.sessionRefresh.errorTitle") + : t("auth.sessionRefresh.loading")} +

+ {failed ? ( +

+ {t("auth.sessionRefresh.errorDescription")} +

+ ) : null} +
+
+ ); +} diff --git a/frontend/app/[locale]/auth/refresh/page.tsx b/frontend/app/[locale]/auth/refresh/page.tsx new file mode 100644 index 00000000..0f18de1f --- /dev/null +++ b/frontend/app/[locale]/auth/refresh/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import SessionRefreshClient from "./SessionRefreshClient"; + +export default function SessionRefreshPage() { + return ( + }> + + + ); +} diff --git a/frontend/app/[locale]/forgot-password/page.tsx b/frontend/app/[locale]/forgot-password/page.tsx index a9ac471c..a928f0a5 100644 --- a/frontend/app/[locale]/forgot-password/page.tsx +++ b/frontend/app/[locale]/forgot-password/page.tsx @@ -3,8 +3,7 @@ import React, { useState } from "react"; import { useTranslations } from "next-intl"; import { Link } from "@/i18n/navigation"; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; +import { ApiError, request } from "@/lib/api-client"; export default function ForgotPasswordPage() { const t = useTranslations("auth.forgotPassword"); @@ -25,17 +24,18 @@ export default function ForgotPasswordPage() { setLoading(true); try { - await fetch(`${API_BASE}/api/auth/forgot-password`, { + await request("/api/v1/auth/forgot-password", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), + anonymous: true, + body: { email }, }); - // The confirmation is shown unconditionally: whether the response was - // ok, not-found, or anything else, the account's existence must never - // leak through this form. setSubmitted(true); - } catch { - setError(t("errorGeneric")); + } catch (error) { + if (error instanceof ApiError && error.kind !== "network") { + setSubmitted(true); + } else { + setError(t("errorGeneric")); + } } finally { setLoading(false); } diff --git a/frontend/app/[locale]/login/LoginForm.tsx b/frontend/app/[locale]/login/LoginForm.tsx index 9e21ffc0..363a5990 100644 --- a/frontend/app/[locale]/login/LoginForm.tsx +++ b/frontend/app/[locale]/login/LoginForm.tsx @@ -14,6 +14,7 @@ import { type LoginResponse, } from "@/lib/auth-session"; import { ErrorBanner } from "@/components/ErrorBanner"; +import { persistOAuthRedirect } from "@/lib/oauth-redirect"; import { Button, Card, @@ -91,22 +92,19 @@ export function LoginForm() { const target = resolvePostLoginPath(searchParams.get("redirect")); - // OAuth is a full-page handoff to the backend, which redirects back with a - // token — so these are plain anchors, not locale-aware client-side links. const oauthHref = (provider: "google" | "github") => - `${API_BASE}/api/auth/${provider}`; + `${API_BASE}/api/v1/auth/${provider}`; async function onSubmit(values: LoginInput) { setSubmitError(null); try { const data = await apiRequest( - `${API_BASE}/api/auth/login`, + "/api/v1/auth/login", { method: "POST", body: values, - // Lets the backend set a session cookie too, for when the token - // stops living in localStorage. credentials: "include", + anonymous: true, }, ); storeSession(data); @@ -226,10 +224,20 @@ export function LoginForm() { diff --git a/frontend/app/[locale]/page.tsx b/frontend/app/[locale]/page.tsx index cdfd4a05..9b395d8f 100644 --- a/frontend/app/[locale]/page.tsx +++ b/frontend/app/[locale]/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { Link } from "@/i18n/navigation"; +import { apiRequest } from "@/lib/api-client"; import { EmptyState } from "@/components/EmptyState"; import Skeleton from "@/components/Skeleton"; @@ -54,7 +55,6 @@ const STATUS_BAR_COLOR: Record = { // Helpers // --------------------------------------------------------------------------- -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; const RECENT_LIMIT = 10; // Max page size the API allows; used as a single-request approximation for // the aggregate stats and risk distribution (no dedicated stats endpoint yet). @@ -136,26 +136,9 @@ export default function DashboardPage() { setLoading(true); setError(null); try { - const token = - typeof window !== "undefined" - ? localStorage.getItem("auth-token") - : null; - - const res = await fetch( - `${API_BASE}/api/documents?page=1&limit=${STATS_SAMPLE_LIMIT}`, - { - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - } + const json = await apiRequest( + `/api/v1/documents?page=1&limit=${STATS_SAMPLE_LIMIT}`, ); - - if (!res.ok) { - throw new Error(`Request failed: ${res.status}`); - } - - const json: PaginatedDocuments = await res.json(); setStats(computeStats(json)); } catch { setError(t("loadError")); diff --git a/frontend/app/[locale]/register/page.tsx b/frontend/app/[locale]/register/page.tsx new file mode 100644 index 00000000..378ffa9d --- /dev/null +++ b/frontend/app/[locale]/register/page.tsx @@ -0,0 +1,5 @@ +import RegisterForm from "@/components/RegisterForm"; + +export default function RegisterPage() { + return ; +} diff --git a/frontend/app/[locale]/reset-password/page.tsx b/frontend/app/[locale]/reset-password/page.tsx index 8fa5c999..e252cc13 100644 --- a/frontend/app/[locale]/reset-password/page.tsx +++ b/frontend/app/[locale]/reset-password/page.tsx @@ -4,8 +4,8 @@ import React, { Suspense, useState } from "react"; import { useTranslations } from "next-intl"; import { useSearchParams } from "next/navigation"; import { Link, useRouter } from "@/i18n/navigation"; +import { ApiError, request } from "@/lib/api-client"; -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; const MIN_PASSWORD_LENGTH = 6; function InvalidTokenNotice({ @@ -80,21 +80,21 @@ function ResetPasswordForm() { setLoading(true); try { - const res = await fetch(`${API_BASE}/api/auth/reset-password`, { + await request("/api/v1/auth/reset-password", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token, password }), + anonymous: true, + body: { token, password }, }); - - if (res.status === 400 || res.status === 401 || res.status === 410) { + router.push("/login?reset=success"); + } catch (error) { + if ( + error instanceof ApiError && + (error.status === 400 || error.status === 401 || error.status === 410) + ) { setTokenInvalid(true); - return; + } else { + setError(t("errorGeneric")); } - if (!res.ok) throw new Error(`Reset failed: ${res.status}`); - - router.push("/login?reset=success"); - } catch { - setError(t("errorGeneric")); } finally { setLoading(false); } diff --git a/frontend/app/[locale]/verify-email/page.tsx b/frontend/app/[locale]/verify-email/page.tsx index ff1bc41b..b6a75ad6 100644 --- a/frontend/app/[locale]/verify-email/page.tsx +++ b/frontend/app/[locale]/verify-email/page.tsx @@ -1,8 +1,9 @@ "use client"; import React, { useEffect, useState } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Link, useRouter } from "@/i18n/navigation"; +import { ApiError, request } from "@/lib/api-client"; export default function VerifyEmailPage() { const searchParams = useSearchParams(); @@ -23,23 +24,21 @@ export default function VerifyEmailPage() { async function verify() { try { - const response = await fetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`, { - method: "GET", - }); - - if (response.ok) { - setStatus("success"); - setTimeout(() => { - router.push("/login"); - }, 3000); - } else { - const data = await response.json().catch(() => ({})); - setStatus("failure"); - setErrorMessage(data.message || "Failed to verify email. The token may be expired or invalid."); - } - } catch (err) { + await request( + `/api/v1/auth/verify-email?token=${encodeURIComponent(token)}`, + { method: "GET", anonymous: true }, + ); + setStatus("success"); + setTimeout(() => { + router.push("/login"); + }, 3000); + } catch (error) { setStatus("failure"); - setErrorMessage("Network error verifying email. Please check your connection."); + setErrorMessage( + error instanceof ApiError && error.backendMessage + ? error.backendMessage + : "Failed to verify email. The token may be expired or invalid.", + ); } } @@ -52,17 +51,12 @@ export default function VerifyEmailPage() { setResendStatus("sending"); try { - const response = await fetch("/api/auth/resend-verification", { + await request("/api/v1/auth/resend-verification", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: resendEmail }), + anonymous: true, + body: { email: resendEmail }, }); - - if (response.ok) { - setResendStatus("sent"); - } else { - setResendStatus("error"); - } + setResendStatus("sent"); } catch { setResendStatus("error"); } diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx index e69de29b..4fc5ecc8 100644 --- a/frontend/app/register/page.tsx +++ b/frontend/app/register/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function RegisterPage() { + redirect("/en/register"); +} diff --git a/frontend/app/robots.ts b/frontend/app/robots.ts index 0cd12681..ef39100e 100644 --- a/frontend/app/robots.ts +++ b/frontend/app/robots.ts @@ -6,7 +6,7 @@ export default function robots(): MetadataRoute.Robots { { userAgent: '*', allow: '/', - disallow: ['/verify/', '/dashboard/', '/api/'], + disallow: ['/', '/verify/', '/api/'], }, ], sitemap: 'https://smalda.org/sitemap.xml', diff --git a/frontend/app/schemas/register.schema.ts b/frontend/app/schemas/register.schema.ts index a3cd54cd..f169abff 100644 --- a/frontend/app/schemas/register.schema.ts +++ b/frontend/app/schemas/register.schema.ts @@ -4,29 +4,31 @@ export const registerSchema = z .object({ fullName: z .string() - .min(2, "Full name is required"), + .min(2, "auth.register.errors.fullNameMin"), email: z .string() - .email("Invalid email address"), + .email("auth.register.errors.emailInvalid"), password: z .string() - .min(8) - .regex(/[A-Z]/) - .regex(/[a-z]/) - .regex(/[0-9]/) - .regex(/[^A-Za-z0-9]/), + .min(8, "auth.register.errors.passwordMin") + .regex(/[A-Z]/, "auth.register.errors.passwordUppercase") + .regex(/[a-z]/, "auth.register.errors.passwordLowercase") + .regex(/[0-9]/, "auth.register.errors.passwordNumber") + .regex(/[^A-Za-z0-9]/, "auth.register.errors.passwordSpecial"), - confirmPassword: z.string(), + confirmPassword: z + .string() + .min(1, "auth.register.errors.confirmRequired"), }) .refine( data => data.password === data.confirmPassword, { path: ["confirmPassword"], - message: "Passwords do not match", + message: "auth.register.errors.passwordMismatch", } ); export type RegisterFormValues = - z.infer; \ No newline at end of file + z.infer; diff --git a/frontend/components/LanguageSwitcher.tsx b/frontend/components/LanguageSwitcher.tsx index 88d304ad..c4e95670 100644 --- a/frontend/components/LanguageSwitcher.tsx +++ b/frontend/components/LanguageSwitcher.tsx @@ -3,59 +3,40 @@ import { useTransition } from "react"; import { useLocale, useTranslations } from "next-intl"; import { usePathname, useRouter } from "@/i18n/navigation"; -import { routing } from "@/i18n/routing"; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; - -/** - * Persist the chosen language to the backend so it can be reused elsewhere - * (e.g. for outgoing emails — see BE-86). Best-effort: a failure here must not - * block the UI from switching languages. - */ -async function persistPreferredLanguage(language: string): Promise { - try { - const token = - typeof window !== "undefined" - ? localStorage.getItem("auth-token") - : null; - - // Not signed in — the language still switches locally via the cookie/URL. - if (!token) return; - - await fetch(`${API_BASE}/api/users/me`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ preferredLanguage: language }), - }); - } catch { - // Swallow — persistence is non-blocking. - } +import { + LOCALE_COOKIE_MAX_AGE, + LOCALE_COOKIE_NAME, + routing, + type Locale, +} from "@/i18n/routing"; + +function persistLocalePreference(locale: Locale): void { + if (typeof document === "undefined") return; + const secure = window.location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `${LOCALE_COOKIE_NAME}=${encodeURIComponent(locale)}; Path=/; Max-Age=${LOCALE_COOKIE_MAX_AGE}; SameSite=Lax${secure}`; } /** * Dropdown that switches the active UI language. Switching triggers a * client-side navigation to the same route under the new locale (no full page - * reload) and, when the user is authenticated, saves the preference to the - * backend. + * reload). */ export default function LanguageSwitcher() { const t = useTranslations("languageSwitcher"); - const locale = useLocale(); + const activeLocale = useLocale() as Locale; const router = useRouter(); const pathname = usePathname(); const [isPending, startTransition] = useTransition(); function handleSelect(nextLocale: string) { - if (nextLocale === locale) return; - - void persistPreferredLanguage(nextLocale); + if (!routing.locales.includes(nextLocale as Locale)) return; + const locale = nextLocale as Locale; + persistLocalePreference(locale); + if (locale === activeLocale) return; startTransition(() => { // `pathname` is locale-agnostic; next-intl re-attaches the new locale. - router.replace(pathname, { locale: nextLocale }); + router.replace(pathname, { locale }); }); } @@ -63,7 +44,7 @@ export default function LanguageSwitcher() {