From 3621ad6eb24c53c92d460ad4e5d5f3e9e4989a61 Mon Sep 17 00:00:00 2001 From: phertyameen Date: Fri, 25 Sep 2026 03:20:21 +0100 Subject: [PATCH] feat(frontend): secure admin routes and restore user context Enforce server-side admin authorization, trap dispute-modal focus, paginate documents, and restore the last viewed map parcel across navigation. Closes #1301 Closes #1302 Closes #1303 Closes #1304 --- backend/.env.example | 6 +- backend/src/auth/access-token.spec.ts | 76 +++ backend/src/auth/access-token.ts | 51 ++ backend/src/auth/auth.controller.spec.ts | 52 ++ backend/src/auth/auth.controller.ts | 181 ++++++- backend/src/auth/auth.service.spec.ts | 17 + backend/src/auth/auth.service.ts | 81 ++- .../src/auth/dto/exchange-oauth-code.dto.ts | 8 + .../src/auth/session-cookie.config.spec.ts | 47 ++ backend/src/auth/session-cookie.config.ts | 84 +++ .../src/auth/strategies/github.strategy.ts | 2 +- .../src/auth/strategies/google.strategy.ts | 2 +- .../src/auth/strategies/jwt.strategy.spec.ts | 29 +- backend/src/auth/strategies/jwt.strategy.ts | 8 +- backend/src/common/cors.config.ts | 9 +- backend/src/config/config.validation.ts | 7 +- backend/src/dispute/dispute.controller.ts | 6 +- backend/src/dispute/dispute.service.spec.ts | 6 +- backend/src/dispute/dispute.service.ts | 14 +- backend/src/documents/documents.controller.ts | 14 +- .../src/documents/documents.service.spec.ts | 18 + backend/src/documents/documents.service.ts | 15 +- .../documents/dto/document-response.dto.ts | 7 +- .../src/documents/dto/list-documents.dto.ts | 21 +- backend/src/mail/mail.service.ts | 2 +- backend/src/main.ts | 2 + docs/DEPLOYMENT.md | 13 + .../app/(protected)/admin/activity/page.tsx | 15 +- .../app/(protected)/admin/audit-logs/page.tsx | 10 +- frontend/app/(protected)/admin/layout.tsx | 12 + .../app/(protected)/map/MapPageContent.tsx | 335 +++++++++++- .../(protected)/admin/documents/page.tsx | 35 +- .../app/[locale]/(protected)/admin/layout.tsx | 15 + .../(protected)/admin/providers/page.tsx | 33 +- .../[locale]/(protected)/admin/users/page.tsx | 39 +- .../(protected)/disputes/[id]/page.tsx | 262 +++------- .../[locale]/(protected)/disputes/page.tsx | 68 ++- .../[locale]/(protected)/documents/page.tsx | 492 +++++++++++++----- .../(protected)/settings/data/page.tsx | 20 +- frontend/app/[locale]/2fa/setup/page.tsx | 9 +- frontend/app/[locale]/2fa/verify/page.tsx | 6 +- .../app/[locale]/forgot-password/page.tsx | 6 +- frontend/app/[locale]/login/LoginForm.tsx | 9 +- frontend/app/[locale]/reset-password/page.tsx | 5 +- frontend/app/[locale]/verify-email/page.tsx | 7 +- frontend/app/api/admin-unavailable/route.ts | 9 + frontend/app/auth/oauth/callback/page.tsx | 82 +++ frontend/app/layout.tsx | 2 + frontend/components/LanguageSwitcher.tsx | 12 +- frontend/components/SessionSync.tsx | 9 + .../components/disputes/FileDisputeModal.tsx | 85 ++- .../__tests__/FileDisputeModal.test.tsx | 88 ++++ .../components/layout/NotificationBell.tsx | 15 +- frontend/components/map/ParcelSidebar.tsx | 43 +- .../components/ui/__tests__/dialog.test.tsx | 13 + frontend/lib/admin-session.ts | 95 ++++ frontend/lib/api-client.ts | 64 +-- frontend/lib/api-config.ts | 45 ++ frontend/lib/auth-session.ts | 37 +- frontend/lib/map-state.ts | 66 +++ frontend/lib/session-expiry-warning.ts | 9 +- frontend/lib/session.ts | 113 ++++ frontend/messages/en.json | 10 + frontend/messages/es.json | 10 + frontend/messages/fr.json | 10 + frontend/middleware.ts | 197 +++++-- frontend/test-utils/LoginForm.test.tsx | 12 +- frontend/test-utils/admin-route-guard.test.ts | 19 + frontend/test-utils/api-client.test.ts | 10 +- frontend/test-utils/api-config.test.ts | 30 ++ frontend/test-utils/auth-session.test.ts | 18 + frontend/test-utils/documents-page.test.tsx | 4 +- .../test-utils/language-switcher.test.tsx | 2 +- frontend/test-utils/map-state.test.ts | 73 +++ frontend/test-utils/mocks/handlers.ts | 55 +- frontend/test-utils/session.test.ts | 50 ++ .../test-utils/user-documents-page.test.tsx | 109 ++++ 77 files changed, 2833 insertions(+), 719 deletions(-) create mode 100644 backend/src/auth/access-token.spec.ts create mode 100644 backend/src/auth/access-token.ts create mode 100644 backend/src/auth/dto/exchange-oauth-code.dto.ts create mode 100644 backend/src/auth/session-cookie.config.spec.ts create mode 100644 backend/src/auth/session-cookie.config.ts create mode 100644 frontend/app/(protected)/admin/layout.tsx create mode 100644 frontend/app/[locale]/(protected)/admin/layout.tsx create mode 100644 frontend/app/api/admin-unavailable/route.ts create mode 100644 frontend/app/auth/oauth/callback/page.tsx create mode 100644 frontend/components/SessionSync.tsx create mode 100644 frontend/components/disputes/__tests__/FileDisputeModal.test.tsx create mode 100644 frontend/lib/admin-session.ts create mode 100644 frontend/lib/api-config.ts create mode 100644 frontend/lib/map-state.ts create mode 100644 frontend/lib/session.ts create mode 100644 frontend/test-utils/admin-route-guard.test.ts create mode 100644 frontend/test-utils/api-config.test.ts create mode 100644 frontend/test-utils/auth-session.test.ts create mode 100644 frontend/test-utils/map-state.test.ts create mode 100644 frontend/test-utils/session.test.ts create mode 100644 frontend/test-utils/user-documents-page.test.tsx diff --git a/backend/.env.example b/backend/.env.example index 06137a8d..7f85c09c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,17 +15,19 @@ JWT_REFRESH_EXPIRATION=7d APP_PORT=6004 APP_URL=http://localhost:6004 FRONTEND_URL=http://localhost:3001 +# Production cross-host browser sessions require a shared parent domain. +# SESSION_COOKIE_DOMAIN=example.com 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:6004/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:6004/api/v1/auth/github/callback # Email Configuration (Nodemailer) MAIL_HOST=smtp.gmail.com diff --git a/backend/src/auth/access-token.spec.ts b/backend/src/auth/access-token.spec.ts new file mode 100644 index 00000000..84e0d076 --- /dev/null +++ b/backend/src/auth/access-token.spec.ts @@ -0,0 +1,76 @@ +import { + ACCESS_TOKEN_COOKIE, + extractSessionAccessToken, + getAccessToken, +} from './access-token'; + +function request(overrides: Partial<{ + headers: Record; + originalUrl: string; +}> = {}) { + return { + headers: overrides.headers ?? {}, + originalUrl: overrides.originalUrl ?? '/api/v1/documents', + } as any; +} + +describe('access token extraction', () => { + it('prefers a bearer token over the session cookie', () => { + const value = getAccessToken( + request({ + headers: { + authorization: 'Bearer bearer-token', + cookie: `${ACCESS_TOKEN_COOKIE}=cookie-token`, + }, + }), + ); + + expect(value).toBe('bearer-token'); + }); + + it('allows the session cookie only for me and logout', () => { + expect( + extractSessionAccessToken( + request({ + headers: { cookie: `${ACCESS_TOKEN_COOKIE}=cookie-token` }, + originalUrl: '/api/v1/auth/me', + }), + ), + ).toBe('cookie-token'); + expect( + extractSessionAccessToken( + request({ + headers: { cookie: `${ACCESS_TOKEN_COOKIE}=cookie-token` }, + originalUrl: '/api/v1/auth/logout/', + }), + ), + ).toBe('cookie-token'); + expect( + extractSessionAccessToken( + request({ + headers: { cookie: `${ACCESS_TOKEN_COOKIE}=cookie-token` }, + originalUrl: '/api/v1/documents', + }), + ), + ).toBeNull(); + }); + + it('rejects empty and oversized cookie values', () => { + expect( + extractSessionAccessToken( + request({ + headers: { cookie: `${ACCESS_TOKEN_COOKIE}=` }, + originalUrl: '/api/v1/auth/me', + }), + ), + ).toBeNull(); + expect( + extractSessionAccessToken( + request({ + headers: { cookie: `${ACCESS_TOKEN_COOKIE}=${'x'.repeat(4097)}` }, + originalUrl: '/api/v1/auth/me', + }), + ), + ).toBeNull(); + }); +}); diff --git a/backend/src/auth/access-token.ts b/backend/src/auth/access-token.ts new file mode 100644 index 00000000..a98dbc9c --- /dev/null +++ b/backend/src/auth/access-token.ts @@ -0,0 +1,51 @@ +import { Request } from 'express'; + +export const ACCESS_TOKEN_COOKIE = 'smalda_access_token'; +export const LEGACY_ACCESS_TOKEN_COOKIE = 'token'; + +function readCookie(request: Request, name: string): string | null { + const header = request.headers.cookie; + if (!header) return null; + + for (const part of header.split(';')) { + const separator = part.indexOf('='); + if (separator < 0) continue; + if (part.slice(0, separator).trim() !== name) continue; + + const rawValue = part.slice(separator + 1).trim(); + if (!rawValue || rawValue.length > 4096) return null; + + try { + const value = decodeURIComponent(rawValue); + return value.length > 0 && value.length <= 4096 ? value : null; + } catch { + return null; + } + } + + return null; +} + +export function getAccessToken(request: Request): string | null { + const authorization = request.headers.authorization; + if (authorization) { + const match = authorization.match(/^Bearer\s+(.+)$/i); + if (match?.[1]) return match[1].trim() || null; + } + + return readCookie(request, ACCESS_TOKEN_COOKIE); +} + +export function getSessionCookieToken(request: Request): string | null { + return readCookie(request, ACCESS_TOKEN_COOKIE); +} + +export function extractSessionAccessToken(request: Request): string | null { + const path = (request.originalUrl || request.url || '') + .split('?')[0] + .replace(/\/+$/, ''); + if (!path.endsWith('/auth/me') && !path.endsWith('/auth/logout')) { + return null; + } + return getSessionCookieToken(request); +} diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts index d1152db8..bb6ece44 100644 --- a/backend/src/auth/auth.controller.spec.ts +++ b/backend/src/auth/auth.controller.spec.ts @@ -3,6 +3,7 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { RolesGuard } from './guards/roles.guard'; +import { ConfigService } from '@nestjs/config'; describe('AuthController', () => { let controller: AuthController; @@ -11,6 +12,9 @@ describe('AuthController', () => { const mockAuthService = { register: jest.fn(), login: jest.fn(), + createOAuthExchangeCode: jest.fn().mockResolvedValue('exchange-code'), + handleOAuthLogin: jest.fn(), + logout: jest.fn().mockResolvedValue(undefined), }; beforeEach(async () => { @@ -21,6 +25,10 @@ describe('AuthController', () => { provide: AuthService, useValue: mockAuthService, }, + { + provide: ConfigService, + useValue: { get: jest.fn().mockReturnValue('development') }, + }, ], }) .overrideGuard(JwtAuthGuard) @@ -54,6 +62,32 @@ describe('AuthController', () => { }); }); + it('sets the secure session cookie when registering', async () => { + const registerDto = { + email: 'cookie@example.com', + password: 'password123', + fullName: 'Cookie User', + }; + mockAuthService.register.mockResolvedValue({ access_token: 'jwt-token' }); + const response = { + cookie: jest.fn(), + clearCookie: jest.fn(), + } as any; + + await controller.register(registerDto, response); + + expect(response.cookie).toHaveBeenCalledWith( + 'smalda_access_token', + 'jwt-token', + expect.objectContaining({ + httpOnly: true, + secure: false, + sameSite: 'lax', + path: '/', + }), + ); + }); + describe('login', () => { it('should call authService.login with the provided dto', async () => { const loginDto = { email: 'test@example.com', password: 'password123' }; @@ -66,4 +100,22 @@ describe('AuthController', () => { expect(result).toEqual(token); }); }); + + it('clears the session cookie when the access token is already expired', async () => { + const response = { + cookie: jest.fn(), + clearCookie: jest.fn(), + } as any; + + await controller.logout({ headers: {} } as any, response); + + expect(response.clearCookie).toHaveBeenCalledWith( + 'smalda_access_token', + expect.objectContaining({ httpOnly: true, path: '/' }), + ); + expect(response.clearCookie).toHaveBeenCalledWith( + 'token', + expect.objectContaining({ httpOnly: true, path: '/' }), + ); + }); }); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index dfa80d44..6534a2f8 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -7,6 +7,8 @@ Res, UseGuards, BadRequestException, + ForbiddenException, + UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AuthGuard } from '@nestjs/passport'; @@ -18,8 +20,17 @@ 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 { ExchangeOAuthCodeDto } from './dto/exchange-oauth-code.dto'; import { getFrontendUrl } from '../common/cors.config'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { User } from '../users/entities/user.entity'; +import { + ACCESS_TOKEN_COOKIE, + LEGACY_ACCESS_TOKEN_COOKIE, + getAccessToken, + getSessionCookieToken, +} from './access-token'; +import { getSessionCookieDomain } from './session-cookie.config'; @Controller('auth') export class AuthController { @@ -29,31 +40,83 @@ export class AuthController { ) {} @Post('register') - register(@Body() dto: RegisterAuthDto) { - return this.authService.register(dto); + async register( + @Body() dto: RegisterAuthDto, + @Res({ passthrough: true }) response?: Response, + ) { + const result = await this.authService.register(dto); + this.setAccessTokenCookie(response, result.access_token); + return result; } @Post('login') - login(@Body() dto: LoginAuthDto) { - return this.authService.login(dto); + async login( + @Body() dto: LoginAuthDto, + @Res({ passthrough: true }) response?: Response, + ) { + const result = await this.authService.login(dto); + this.setAccessTokenCookie(response, result.access_token); + return result; } @Post('refresh') - refresh(@Body() dto: RefreshAuthDto) { - return this.authService.refreshToken(dto); + async refresh( + @Body() dto: RefreshAuthDto, + @Res({ passthrough: true }) response?: Response, + ) { + const result = await this.authService.refreshToken(dto); + this.setAccessTokenCookie(response, result.access_token); + return result; } - @Post('logout') + @Get('me') @UseGuards(JwtAuthGuard) - async logout(@Req() req: Request) { - const authHeader = req.headers.authorization; - if (authHeader) { - const token = authHeader.replace('Bearer ', ''); - await this.authService.logout(token); + me(@Req() req: Request & { user?: User }) { + const user = req.user; + if (!user) { + throw new UnauthorizedException('Authentication required'); + } + + return { + id: user.id, + email: user.email, + fullName: user.fullName, + role: user.role, + }; + } + + @Post('logout') + async logout( + @Req() req: Request, + @Res({ passthrough: true }) response?: Response, + @Body('refreshToken') refreshToken?: string, + ) { + this.assertAllowedMutationOrigin(req); + const token = getAccessToken(req); + const normalizedRefreshToken = + typeof refreshToken === 'string' ? refreshToken.trim() : undefined; + if (token) { + await this.authService.logout(token, normalizedRefreshToken); + } else if (normalizedRefreshToken) { + await this.authService.logout(undefined, normalizedRefreshToken); } + this.clearAccessTokenCookie(response, ACCESS_TOKEN_COOKIE); + this.clearAccessTokenCookie(response, LEGACY_ACCESS_TOKEN_COOKIE); return { message: 'Logged out successfully' }; } + @Post('oauth/exchange') + async exchangeOAuthCode( + @Req() req: Request, + @Body() dto: ExchangeOAuthCodeDto, + @Res({ passthrough: true }) response?: Response, + ) { + this.assertAllowedMutationOrigin(req); + const tokens = await this.authService.exchangeOAuthCode(dto.code); + this.setAccessTokenCookie(response, tokens.access_token); + return tokens; + } + @Get('google') @UseGuards(AuthGuard('google')) googleAuth() { @@ -80,12 +143,14 @@ 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); + this.setAccessTokenCookie(res, tokens.access_token); + const code = await this.authService.createOAuthExchangeCode(tokens); + return this.redirectWithOAuthCode(code, res); } @Get('github') @@ -112,21 +177,101 @@ export class AuthController { 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); + this.setAccessTokenCookie(res, tokens.access_token); + const code = await this.authService.createOAuthExchangeCode(tokens); + return this.redirectWithOAuthCode(code, res); + } + + private setAccessTokenCookie( + response: Response | undefined, + accessToken: string, + ): void { + if (!response || typeof response.cookie !== 'function') return; + + response.cookie(ACCESS_TOKEN_COOKIE, accessToken, { + ...this.getAccessTokenCookieOptions(), + }); } - private redirectWithToken(accessToken: string, res: Response) { + private clearAccessTokenCookie( + response: Response | undefined, + cookieName: string, + ): void { + if (!response || typeof response.clearCookie !== 'function') return; + + response.clearCookie(cookieName, { + ...this.getAccessTokenCookieOptions(), + }); + } + + private getAccessTokenCookieOptions() { + const isProduction = + this.configService.get('NODE_ENV') === 'production'; + + const domain = getSessionCookieDomain(this.configService); + + return { + httpOnly: true, + secure: isProduction, + sameSite: isProduction ? ('none' as const) : ('lax' as const), + path: '/', + ...(domain ? { domain } : {}), + }; + } + + private redirectWithOAuthCode(code: string, res: Response) { const frontendUrl = getFrontendUrl(this.configService); const redirectUrl = new URL(frontendUrl); - redirectUrl.searchParams.set('token', accessToken); + redirectUrl.pathname = `${redirectUrl.pathname.replace(/\/$/, '')}/auth/oauth/callback`; + redirectUrl.search = ''; + redirectUrl.hash = ''; + redirectUrl.searchParams.set('code', code); + if (typeof res.setHeader === 'function') { + res.setHeader('Cache-Control', 'no-store'); + } return res.redirect(redirectUrl.toString()); } + private assertAllowedMutationOrigin(req: Request): void { + const origin = req.headers.origin; + const hasSessionCookie = Boolean(getSessionCookieToken(req)); + if (!origin) { + if (hasSessionCookie) { + throw new ForbiddenException('Origin is required for cookie logout'); + } + return; + } + + let normalizedOrigin: string; + try { + normalizedOrigin = new URL(origin).origin; + } catch { + throw new ForbiddenException('Origin is not allowed'); + } + + const configuredOrigins = [ + this.configService.get('APP_URL'), + ...(this.configService.get('FRONTEND_URL') ?? '').split(','), + ] + .filter((value): value is string => Boolean(value?.trim())) + .map((value) => { + try { + return new URL(value.trim()).origin; + } catch { + return ''; + } + }); + + if (!configuredOrigins.includes(normalizedOrigin)) { + throw new ForbiddenException('Origin is not allowed'); + } + } + 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..a9462a70 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -106,6 +106,23 @@ describe('AuthService', () => { }); }); + describe('OAuth exchange codes', () => { + it('consumes a code exactly once', async () => { + const code = await service.createOAuthExchangeCode({ + access_token: 'access-token', + refresh_token: 'refresh-token', + }); + + await expect(service.exchangeOAuthCode(code)).resolves.toEqual({ + access_token: 'access-token', + refresh_token: 'refresh-token', + }); + await expect(service.exchangeOAuthCode(code)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + describe('logout()', () => { it('should add token to blacklist', async () => { mockJwtService.decode.mockReturnValue({ diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index c5b9ab7c..b4ebd06c 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -7,6 +7,7 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; +import { randomBytes } from 'crypto'; import { UsersService } from '../users/users.service'; import { RegisterAuthDto } from './dto/register-auth.dto'; @@ -18,6 +19,11 @@ import { User, UserRole } from '../users/entities/user.entity'; @Injectable() export class AuthService { private readonly tokenBlacklist = new Set(); + private readonly refreshTokenBlacklist = new Set(); + private readonly oauthExchangeCodes = new Map< + string, + { accessToken: string; refreshToken: string; expiresAt: number } + >(); constructor( private readonly usersService: UsersService, @@ -76,7 +82,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 refreshToken(dto: RefreshAuthDto) { @@ -86,6 +93,10 @@ export class AuthService { } try { + if (this.refreshTokenBlacklist.has(refreshToken)) { + throw new UnauthorizedException('Refresh token has been revoked'); + } + const payload = await this.jwtService.verifyAsync( refreshToken, { @@ -105,25 +116,69 @@ export class AuthService { } } - 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(accessToken: string | undefined, refreshToken?: string): Promise { + if (accessToken) this.blacklistToken(accessToken, this.tokenBlacklist); + if (refreshToken) { + this.blacklistToken(refreshToken, this.refreshTokenBlacklist); + } + } + + async createOAuthExchangeCode(tokens: { + access_token: string; + refresh_token: string; + }): Promise { + const code = randomBytes(32).toString('hex'); + const expiresAt = Date.now() + 60_000; + this.oauthExchangeCodes.set(code, { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt, + }); + setTimeout(() => this.oauthExchangeCodes.delete(code), 60_000); + return code; + } + + async exchangeOAuthCode(code: string): Promise<{ + access_token: string; + refresh_token: string; + }> { + const normalizedCode = code.trim(); + const exchange = this.oauthExchangeCodes.get(normalizedCode); + this.oauthExchangeCodes.delete(normalizedCode); + + if (!exchange || exchange.expiresAt <= Date.now()) { + throw new UnauthorizedException('OAuth exchange code is invalid or expired'); } + + return { + access_token: exchange.accessToken, + refresh_token: exchange.refreshToken, + }; } isTokenBlacklisted(token: string): boolean { return this.tokenBlacklist.has(token); } + isRefreshTokenBlacklisted(token: string): boolean { + return this.refreshTokenBlacklist.has(token); + } + + private blacklistToken(token: string, blacklist: Set): void { + try { + const payload = this.jwtService.decode(token); + if (!payload?.exp) return; + + const ttl = payload.exp - Math.floor(Date.now() / 1000); + if (ttl <= 0) return; + + blacklist.add(token); + setTimeout(() => blacklist.delete(token), ttl * 1000); + } catch { + return; + } + } + private async validateCredentials(email: string, password: string) { const user = await this.usersService.findByEmail(email); if (!user) { diff --git a/backend/src/auth/dto/exchange-oauth-code.dto.ts b/backend/src/auth/dto/exchange-oauth-code.dto.ts new file mode 100644 index 00000000..324896ac --- /dev/null +++ b/backend/src/auth/dto/exchange-oauth-code.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class ExchangeOAuthCodeDto { + @IsString() + @IsNotEmpty() + @MaxLength(128) + code: string; +} diff --git a/backend/src/auth/session-cookie.config.spec.ts b/backend/src/auth/session-cookie.config.spec.ts new file mode 100644 index 00000000..52827663 --- /dev/null +++ b/backend/src/auth/session-cookie.config.spec.ts @@ -0,0 +1,47 @@ +import { ConfigService } from '@nestjs/config'; +import { + getSessionCookieDomain, + validateSessionCookieTopology, +} from './session-cookie.config'; + +function config(values: Record): ConfigService { + return { get: (key: string) => values[key] } as ConfigService; +} + +describe('session cookie topology', () => { + it('allows same-host origins without a parent domain', () => { + expect(() => + validateSessionCookieTopology( + config({ + NODE_ENV: 'production', + APP_URL: 'https://app.example.test', + FRONTEND_URL: 'https://app.example.test', + }), + ), + ).not.toThrow(); + }); + + it('rejects cross-host production cookies without a shared domain', () => { + expect(() => + validateSessionCookieTopology( + config({ + NODE_ENV: 'production', + APP_URL: 'https://api.example.test', + FRONTEND_URL: 'https://app.example.test', + }), + ), + ).toThrow('SESSION_COOKIE_DOMAIN'); + }); + + it('accepts a shared parent domain and returns its normalized value', () => { + const service = config({ + NODE_ENV: 'production', + APP_URL: 'https://api.example.test', + FRONTEND_URL: 'https://app.example.test', + SESSION_COOKIE_DOMAIN: '.Example.Test', + }); + + expect(() => validateSessionCookieTopology(service)).not.toThrow(); + expect(getSessionCookieDomain(service)).toBe('example.test'); + }); +}); diff --git a/backend/src/auth/session-cookie.config.ts b/backend/src/auth/session-cookie.config.ts new file mode 100644 index 00000000..d70c0a9e --- /dev/null +++ b/backend/src/auth/session-cookie.config.ts @@ -0,0 +1,84 @@ +import { ConfigService } from '@nestjs/config'; + +function normalizeHostname(hostname: string): string { + return hostname.trim().toLowerCase().replace(/^\.+|\.+$/g, ''); +} + +function parseOrigins(value: string | undefined, name: string): URL[] { + if (!value) { + throw new Error(`${name} must be configured.`); + } + + return value.split(',').map((entry) => { + const trimmed = entry.trim(); + if (!trimmed) { + throw new Error(`${name} contains an empty origin.`); + } + + try { + const url = new URL(trimmed); + if (url.protocol !== 'https:' || url.search || url.hash) { + throw new Error('invalid origin'); + } + return url; + } catch { + throw new Error(`${name} must contain only absolute HTTPS origins in production.`); + } + }); +} + +function isWithinDomain(hostname: string, domain: string): boolean { + return hostname === domain || hostname.endsWith(`.${domain}`); +} + +export function validateSessionCookieTopology(configService: ConfigService): void { + if (configService.get('NODE_ENV') !== 'production') return; + + const appOrigins = parseOrigins(configService.get('APP_URL'), 'APP_URL'); + const frontendOrigins = parseOrigins( + configService.get('FRONTEND_URL'), + 'FRONTEND_URL', + ); + const appHost = normalizeHostname(appOrigins[0].hostname); + const frontendHosts = frontendOrigins.map((origin) => + normalizeHostname(origin.hostname), + ); + + const configuredDomain = normalizeHostname( + configService.get('SESSION_COOKIE_DOMAIN') ?? '', + ); + if (frontendHosts.every((hostname) => hostname === appHost)) { + if ( + configuredDomain && + (!isWithinDomain(appHost, configuredDomain) || + frontendHosts.some((hostname) => !isWithinDomain(hostname, configuredDomain))) + ) { + throw new Error( + `SESSION_COOKIE_DOMAIN "${configuredDomain}" must contain APP_URL and every FRONTEND_URL host.`, + ); + } + return; + } + + if (!configuredDomain) { + throw new Error( + 'Production browser sessions require same-origin API/frontend hosting or SESSION_COOKIE_DOMAIN set to a shared parent domain. Host-only cookies cannot authenticate a different production host.', + ); + } + + if ( + !isWithinDomain(appHost, configuredDomain) || + frontendHosts.some((hostname) => !isWithinDomain(hostname, configuredDomain)) + ) { + throw new Error( + `SESSION_COOKIE_DOMAIN "${configuredDomain}" must be a shared parent domain of APP_URL and every FRONTEND_URL host.`, + ); + } +} + +export function getSessionCookieDomain(configService: ConfigService): string | undefined { + if (configService.get('NODE_ENV') !== 'production') return undefined; + + const configured = configService.get('SESSION_COOKIE_DOMAIN'); + return configured ? normalizeHostname(configured) : undefined; +} diff --git a/backend/src/auth/strategies/github.strategy.ts b/backend/src/auth/strategies/github.strategy.ts index 3ee665b0..ffa3d5f5 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:6004'}/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..0bba74ed 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:6004'}/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..cce37fe4 100644 --- a/backend/src/auth/strategies/jwt.strategy.spec.ts +++ b/backend/src/auth/strategies/jwt.strategy.spec.ts @@ -1,9 +1,10 @@ import { JwtStrategy } from './jwt.strategy'; import { UsersService } from '../../users/users.service'; +import { AuthService } from '../auth.service'; import { ConfigService } from '@nestjs/config'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { User } from '../../users/entities/user.entity'; +import { User, UserRole } from '../../users/entities/user.entity'; 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(); @@ -40,22 +47,30 @@ describe('JwtStrategy', () => { it('should return the user if found', async () => { const user = new User(); user.id = '1'; - const payload = { sub: '1', email: 'test@example.com' }; + const payload = { + sub: '1', + email: 'test@example.com', + role: UserRole.USER, + }; jest.spyOn(usersService, 'findById').mockResolvedValue(user); - const result = await strategy.validate(payload); + const result = await strategy.validate({ headers: {} }, payload); expect(usersService.findById).toHaveBeenCalledWith(payload.sub); expect(result).toEqual(user); }); it('should throw an UnauthorizedException if user is not found', async () => { - const payload = { sub: '1', email: 'test@example.com' }; + const payload = { + sub: '1', + email: 'test@example.com', + role: UserRole.USER, + }; jest.spyOn(usersService, 'findById').mockResolvedValue(null); - await expect(strategy.validate(payload)).rejects.toThrow( - UnauthorizedException, - ); + await expect( + strategy.validate({ headers: {} }, payload), + ).rejects.toThrow(UnauthorizedException); }); }); }); diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index 405667c5..69bfedf6 100644 --- a/backend/src/auth/strategies/jwt.strategy.ts +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -6,6 +6,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { UsersService } from '../../users/users.service'; import { AuthService } from '../auth.service'; +import { extractSessionAccessToken, getAccessToken } from '../access-token'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -15,7 +16,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) { configService: ConfigService, ) { super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: ExtractJwt.fromExtractors([ + ExtractJwt.fromAuthHeaderAsBearerToken(), + extractSessionAccessToken, + ]), secretOrKey: configService.get('JWT_SECRET'), ignoreExpiration: false, passReqToCallback: true, @@ -23,7 +27,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(request: any, payload: JwtPayload) { - const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); + const token = getAccessToken(request); if (token && this.authService.isTokenBlacklisted(token)) { throw new UnauthorizedException('Token has been revoked'); } diff --git a/backend/src/common/cors.config.ts b/backend/src/common/cors.config.ts index aba5284c..895a1cf5 100644 --- a/backend/src/common/cors.config.ts +++ b/backend/src/common/cors.config.ts @@ -76,7 +76,14 @@ function parseAllowlist( .split(',') .map((o) => o.trim()) .filter((o) => o.length > 0) - .forEach((o) => allowlist.add(o)); + .forEach((o) => { + try { + const origin = new URL(o).origin; + allowlist.add(origin); + } catch { + throw new Error(`Invalid FRONTEND_URL origin: ${o}`); + } + }); } if (allowDevFallback && allowlist.size === 0) { diff --git a/backend/src/config/config.validation.ts b/backend/src/config/config.validation.ts index e613723d..1d5de805 100644 --- a/backend/src/config/config.validation.ts +++ b/backend/src/config/config.validation.ts @@ -1,5 +1,9 @@ import * as Joi from 'joi'; +const originList = Joi.string() + .pattern(/^https?:\/\/[^,\s]+(?:\s*,\s*https?:\/\/[^,\s]+)*$/) + .required(); + /** * Custom validation to ensure placeholder values are not used in production */ @@ -32,7 +36,8 @@ export const ConfigValidationSchema = Joi.object({ // ── Server ───────────────────────────────────────────────────────────────── APP_PORT: Joi.number().positive().default(3001), APP_URL: Joi.string().uri().required(), - FRONTEND_URL: Joi.string().uri().required(), + FRONTEND_URL: originList, + SESSION_COOKIE_DOMAIN: Joi.string().allow('').optional(), // ── Database ─────────────────────────────────────────────────────────────── DATABASE_HOST: Joi.string().required(), diff --git a/backend/src/dispute/dispute.controller.ts b/backend/src/dispute/dispute.controller.ts index f953ab27..0ebad0af 100644 --- a/backend/src/dispute/dispute.controller.ts +++ b/backend/src/dispute/dispute.controller.ts @@ -66,8 +66,12 @@ export class DisputeController { @Param('id') id: string, @Req() req: Request & { user?: User }, ): Promise { - const dispute = await this.disputeService.findOne(id); const user = req.user!; + const dispute = await this.disputeService.findOne( + id, + user.id, + user.role === 'admin', + ); if (dispute.filedBy !== user.id && user.role !== 'admin') { throw new ForbiddenException('Access denied'); diff --git a/backend/src/dispute/dispute.service.spec.ts b/backend/src/dispute/dispute.service.spec.ts index daee0590..0edc3877 100644 --- a/backend/src/dispute/dispute.service.spec.ts +++ b/backend/src/dispute/dispute.service.spec.ts @@ -4,7 +4,7 @@ import { DisputeService } from './dispute.service'; import { Dispute, DisputeStatus } from './entities/dispute.entity'; import { DisputeReasonClassifierService } from './dispute-reason-classifier.service'; import { AccessLogsService } from '../access-logs/access-logs.service'; -import { NotFoundException } from '@nestjs/common'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; const mockDispute = { id: 'dispute-1', @@ -174,7 +174,7 @@ describe('DisputeService', () => { ); }); - it('should throw UnauthorizedException if a user tries to access a dispute they did not file', async () => { + it('should throw ForbiddenException if a user tries to access a dispute they did not file', async () => { const disputeId = 'dispute-id-1'; const dispute: Dispute = { id: disputeId, @@ -189,7 +189,7 @@ describe('DisputeService', () => { await expect( service.findOne(disputeId, 'another-user-id'), - ).rejects.toThrow('Unauthorized access'); + ).rejects.toThrow(ForbiddenException); }); }); }); diff --git a/backend/src/dispute/dispute.service.ts b/backend/src/dispute/dispute.service.ts index 1b38ada4..96a62d5a 100644 --- a/backend/src/dispute/dispute.service.ts +++ b/backend/src/dispute/dispute.service.ts @@ -1,9 +1,9 @@ import { + BadRequestException, Injectable, + ForbiddenException, NotFoundException, - UnauthorizedException, } from '@nestjs/common'; -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Dispute, DisputeStatus, ALLOWED_DISPUTE_TRANSITIONS } from './entities/dispute.entity'; @@ -94,13 +94,17 @@ export class DisputeService { }; } - async findOne(id: string, userId: string): Promise { + async findOne( + id: string, + userId: string, + allowAdmin = false, + ): Promise { const dispute = await this.disputeRepo.findOne({ where: { id } }); if (!dispute) { throw new NotFoundException(`Dispute ${id} not found`); } - if (dispute.filedBy !== userId) { - throw new UnauthorizedException('Unauthorized access'); + if (dispute.filedBy !== userId && !allowAdmin) { + throw new ForbiddenException('Forbidden access'); } return this.toResponseDto(dispute); } diff --git a/backend/src/documents/documents.controller.ts b/backend/src/documents/documents.controller.ts index ee818019..3ecf5ce2 100644 --- a/backend/src/documents/documents.controller.ts +++ b/backend/src/documents/documents.controller.ts @@ -139,6 +139,7 @@ export class DocumentsController { query.page!, query.limit!, query.status, + query.search, ); return { @@ -287,13 +288,22 @@ export class DocumentsController { } } +function toNullableNumber(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null; + const numberValue = typeof value === 'number' ? value : Number(value); + return Number.isFinite(numberValue) ? numberValue : null; +} + function toDocumentResponse(document: Document): DocumentResponseDto { return { id: document.id, title: document.title, status: document.status, - riskScore: document.riskScore, - riskFlags: document.riskFlags, + riskScore: toNullableNumber(document.riskScore), + riskFlags: document.riskFlags ?? null, + fileSize: document.fileSize, + latitude: toNullableNumber(document.latitude), + longitude: toNullableNumber(document.longitude), createdAt: document.createdAt, updatedAt: document.updatedAt, }; diff --git a/backend/src/documents/documents.service.spec.ts b/backend/src/documents/documents.service.spec.ts index 43faa0ca..b7ce09f4 100644 --- a/backend/src/documents/documents.service.spec.ts +++ b/backend/src/documents/documents.service.spec.ts @@ -190,6 +190,24 @@ describe('DocumentsService', () => { expect(result.page).toBe(2); }); + it('should apply a title search when provided', async () => { + mockRepository.findAndCount.mockResolvedValueOnce([[], 0]); + + await service.findByOwnerPaginated( + 'user-456', + 1, + 20, + undefined, + 'land title', + ); + + expect(mockRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ title: expect.anything() }), + }), + ); + }); + it('should compute correct skip for page 3 with limit 5', async () => { mockRepository.findAndCount.mockResolvedValueOnce([[], 0]); diff --git a/backend/src/documents/documents.service.ts b/backend/src/documents/documents.service.ts index 708c14f1..6dfb694b 100644 --- a/backend/src/documents/documents.service.ts +++ b/backend/src/documents/documents.service.ts @@ -1,6 +1,12 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + Repository, + Between, + LessThanOrEqual, + MoreThanOrEqual, + ILike, +} from 'typeorm'; import { promises as fs } from 'fs'; import { Document, DocumentStatus } from './entities/document.entity'; @@ -29,17 +35,22 @@ export class DocumentsService { page: number, limit: number, status?: DocumentStatus, + search?: string, ): Promise<{ data: Document[]; total: number; page: number; limit: number }> { const where: any = { ownerId }; if (status) { where.status = status; } + const normalizedSearch = search?.trim(); + if (normalizedSearch) { + where.title = ILike(`%${normalizedSearch}%`); + } const [data, total] = await this.documentRepository.findAndCount({ where, skip: (page - 1) * limit, take: limit, - order: { createdAt: 'DESC' }, + order: { createdAt: 'DESC', id: 'DESC' }, }); return { data, total, page, limit }; diff --git a/backend/src/documents/dto/document-response.dto.ts b/backend/src/documents/dto/document-response.dto.ts index d87c6b4e..dcdf57fb 100644 --- a/backend/src/documents/dto/document-response.dto.ts +++ b/backend/src/documents/dto/document-response.dto.ts @@ -2,8 +2,11 @@ export class DocumentResponseDto { id: string; title: string; status: string; - riskScore?: number; - riskFlags?: string[]; + riskScore: number | null; + riskFlags: string[] | null; + fileSize: number; + latitude: number | null; + longitude: number | null; createdAt: Date; updatedAt: Date; } diff --git a/backend/src/documents/dto/list-documents.dto.ts b/backend/src/documents/dto/list-documents.dto.ts index dde96e43..2e31300b 100644 --- a/backend/src/documents/dto/list-documents.dto.ts +++ b/backend/src/documents/dto/list-documents.dto.ts @@ -1,22 +1,39 @@ -import { IsOptional, IsInt, Min, IsEnum, Max } from 'class-validator'; +import { + IsOptional, + IsInt, + Min, + IsEnum, + Max, + IsString, + MaxLength, +} from 'class-validator'; import { Type } from 'class-transformer'; import { DocumentStatus } from '../entities/document.entity'; +export const MAX_DOCUMENT_LIMIT = 100; +export const MAX_DOCUMENT_PAGE = 10000; + export class ListDocumentsDto { @IsOptional() @Type(() => Number) @IsInt() @Min(1) - @Max(100) + @Max(MAX_DOCUMENT_LIMIT) limit?: number = 20; @IsOptional() @Type(() => Number) @IsInt() @Min(1) + @Max(MAX_DOCUMENT_PAGE) page?: number = 1; @IsOptional() @IsEnum(DocumentStatus) status?: DocumentStatus; + + @IsOptional() + @IsString() + @MaxLength(100) + search?: string; } diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts index 4e668f3f..197c2dc5 100644 --- a/backend/src/mail/mail.service.ts +++ b/backend/src/mail/mail.service.ts @@ -65,7 +65,7 @@ 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 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..e95451b1 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -11,6 +11,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { WinstonModule } from 'nest-winston'; import { buildWinstonOptions } from './common/logger.config'; import { buildCorsOptions } from './common/cors.config'; +import { validateSessionCookieTopology } from './auth/session-cookie.config'; import { config as loadEnv } from 'dotenv'; loadEnv({ path: '.env' }); @@ -22,6 +23,7 @@ async function bootstrap() { app.enableShutdownHooks(); const configService = app.get(ConfigService); + validateSessionCookieTopology(configService); // Enable hardened CORS const { corsOptions } = buildCorsOptions(configService); diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0ba712d4..c8c1ae88 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -15,6 +15,19 @@ 3. Deploy API backend and frontend artifacts. 4. Execute smoke tests on `/health` and `/metrics`. +## Browser Session Topology + +Production browser sessions must use one of these supported topologies: + +1. Serve the frontend and API through the same origin, including a reverse proxy or path-based gateway. +2. Serve the frontend and API on sibling hosts under one explicitly configured parent domain, such as `app.example.com` and `api.example.com`, and set `SESSION_COOKIE_DOMAIN=example.com`. + +A host-only cookie cannot authenticate a middleware request on a different production host. The backend refuses production startup when `FRONTEND_URL` and `APP_URL` are on different hosts without a valid shared parent domain. Configure HTTPS and the exact frontend origin before enabling credentialed cookies. + +OAuth callbacks redirect with a short-lived, one-time exchange code, never a JWT. The frontend exchanges it at `/api/v1/auth/oauth/exchange` and stores the returned session locally. + +Logout writes a local-storage `logout-event`; the client session synchronizer clears each tab's user-scoped map selection before redirecting. `sessionStorage` is intentionally not treated as remotely clearable. + ## Rollback Procedure 1. If deployment fails before DB migration: revert container version. diff --git a/frontend/app/(protected)/admin/activity/page.tsx b/frontend/app/(protected)/admin/activity/page.tsx index 5ac64376..3e2d823d 100644 --- a/frontend/app/(protected)/admin/activity/page.tsx +++ b/frontend/app/(protected)/admin/activity/page.tsx @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useState } from "react"; import { CircleUser } from "lucide-react"; +import { apiUrl } from "@/lib/api-config"; +import { getAccessToken } from "@/lib/session"; // --------------------------------------------------------------------------- // Types @@ -27,11 +29,8 @@ interface PaginatedActivity { // Helpers // --------------------------------------------------------------------------- -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; - function getAuthHeaders(): HeadersInit { - const token = - typeof window !== "undefined" ? localStorage.getItem("auth-token") : null; + const token = getAccessToken(); return { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), @@ -107,10 +106,10 @@ export default function AdminActivityPage() { if (appliedActionType) params.set("actionType", appliedActionType); try { - const res = await fetch( - `${API_BASE}/api/admin/activity?${params.toString()}`, - { headers: getAuthHeaders() }, - ); + const res = await fetch(apiUrl("/admin/activity", params), { + credentials: "include", + headers: getAuthHeaders(), + }); if (res.status === 403) { setAccessDenied(true); return; diff --git a/frontend/app/(protected)/admin/audit-logs/page.tsx b/frontend/app/(protected)/admin/audit-logs/page.tsx index ca6b16b0..639faf87 100644 --- a/frontend/app/(protected)/admin/audit-logs/page.tsx +++ b/frontend/app/(protected)/admin/audit-logs/page.tsx @@ -8,6 +8,8 @@ import { CardDescription, CardContent, } from "@/components/ui/card"; +import { apiUrl } from "@/lib/api-config"; +import { getAccessToken } from "@/lib/session"; import { Table, TableHeader, @@ -34,13 +36,10 @@ interface PaginatedAccessLogs { totalPages: number; } -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; const PAGE_SIZE = 20; function getAuthHeaders(): HeadersInit { - const token = - typeof window !== "undefined" ? localStorage.getItem("auth-token") : null; - + const token = getAccessToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } @@ -73,7 +72,8 @@ export default function AdminAuditLogsPage() { if (appliedFilters.endDate) params.set("endDate", appliedFilters.endDate); try { - const response = await fetch(`${API_BASE}/admin/access-logs?${params}`, { + const response = await fetch(apiUrl("/admin/access-logs", params), { + credentials: "include", headers: getAuthHeaders(), }); if (!response.ok) { diff --git a/frontend/app/(protected)/admin/layout.tsx b/frontend/app/(protected)/admin/layout.tsx new file mode 100644 index 00000000..1d053333 --- /dev/null +++ b/frontend/app/(protected)/admin/layout.tsx @@ -0,0 +1,12 @@ +import { requireAdminSession } from "@/lib/admin-session"; + +export const dynamic = "force-dynamic"; + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + await requireAdminSession("en"); + return children; +} diff --git a/frontend/app/(protected)/map/MapPageContent.tsx b/frontend/app/(protected)/map/MapPageContent.tsx index e882a349..7de5f31f 100644 --- a/frontend/app/(protected)/map/MapPageContent.tsx +++ b/frontend/app/(protected)/map/MapPageContent.tsx @@ -1,6 +1,16 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import ParcelSidebar, { + type ParcelDocument, +} from "@/components/map/ParcelSidebar"; +import { apiUrl } from "@/lib/api-config"; +import { getAccessToken } from "@/lib/session"; +import { + clearLastViewedParcel, + readLastViewedParcelId, + saveLastViewedParcelId, +} from "@/lib/map-state"; import "leaflet/dist/leaflet.css"; import * as L from "leaflet"; import { @@ -11,7 +21,8 @@ import { ZoomControl, } from "react-leaflet"; -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; +const MAP_PAGE_SIZE = 100; +const MAP_MAX_DOCUMENTS = 1000; type DocumentStatus = | "VERIFIED" @@ -24,7 +35,8 @@ interface DocumentWithLocation { id: string; title: string; status: DocumentStatus; - riskScore: number; + riskScore: number | null; + riskFlags: string[] | null; latitude: number; longitude: number; } @@ -46,10 +58,144 @@ const LABEL_CLASSES: Record = { }; function getAuthHeaders(): HeadersInit { - const token = localStorage.getItem("auth-token"); + const token = getAccessToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function normalizeStatus(value: unknown): DocumentStatus { + const status = typeof value === "string" ? value.toUpperCase() : ""; + if ( + status === "VERIFIED" || + status === "PENDING" || + status === "FLAGGED" || + status === "REJECTED" || + status === "ANALYZING" + ) { + return status; + } + return "PENDING"; +} + +function toDocumentWithLocation(value: unknown): DocumentWithLocation | null { + if (!isRecord(value)) return null; + + const id = value.id; + const title = value.title; + if (typeof id !== "string" || typeof title !== "string") return null; + + if ( + value.latitude == null || + value.longitude == null || + value.latitude === "" || + value.longitude === "" + ) { + return null; + } + + const latitude = Number(value.latitude); + const longitude = Number(value.longitude); + if ( + !Number.isFinite(latitude) || + !Number.isFinite(longitude) || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + return null; + } + + return { + id, + title, + status: normalizeStatus(value.status), + riskScore: + typeof value.riskScore === "number" && Number.isFinite(value.riskScore) + ? value.riskScore + : null, + riskFlags: Array.isArray(value.riskFlags) + ? value.riskFlags.filter((flag): flag is string => typeof flag === "string") + : null, + latitude, + longitude, + }; +} + +type ParcelLookup = + | { kind: "found"; document: DocumentWithLocation } + | { kind: "missing" } + | { kind: "transient" }; + +async function fetchDocumentById(id: string): Promise { + try { + const response = await fetch( + apiUrl(`/documents/${encodeURIComponent(id)}`), + { credentials: "include", headers: getAuthHeaders() }, + ); + if (response.status === 403 || response.status === 404) { + return { kind: "missing" }; + } + if (!response.ok) return { kind: "transient" }; + + const payload: unknown = await response.json(); + if ( + !isRecord(payload) || + typeof payload.id !== "string" || + typeof payload.title !== "string" + ) { + return { kind: "transient" }; + } + + const document = toDocumentWithLocation(payload); + return document ? { kind: "found", document } : { kind: "missing" }; + } catch { + return { kind: "transient" }; + } +} + +async function fetchCurrentUserId(): Promise { + try { + const response = await fetch(apiUrl("/auth/me"), { + credentials: "include", + headers: getAuthHeaders(), + cache: "no-store", + }); + if (!response.ok) return null; + + const body: unknown = await response.json(); + if (!isRecord(body) || typeof body.id !== "string" || !body.id) { + return null; + } + return body.id; + } catch { + return null; + } +} + +function toRiskScore(value: number | null): number | null { + if (value == null) return null; + return Math.max(0, Math.min(100, Math.round(value))); +} + +function toParcelDocument(document: DocumentWithLocation): ParcelDocument { + return { + id: document.id, + name: document.title, + status: document.status, + riskScore: toRiskScore(document.riskScore), + ownerName: null, + isOwnedByViewer: true, + stellarAnchorDate: null, + stellarTxHash: null, + flags: document.riskFlags ?? [], + detailsUrl: `/documents/${document.id}`, + }; +} + function createColouredIcon(colour: string) { return L.divIcon({ className: "", @@ -68,26 +214,124 @@ export default function MapPageContent() { const [userRegion, setUserRegion] = useState<[number, number] | null>(null); const [tileError, setTileError] = useState(false); const [tileKey, setTileKey] = useState(0); + const [selectedParcelId, setSelectedParcelId] = useState(null); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [mapTotal, setMapTotal] = useState(0); + const [mapLoadedCount, setMapLoadedCount] = useState(0); + const [mapLimited, setMapLimited] = useState(false); const mapRef = useRef(null); + const mapUserIdRef = useRef(null); const fetchDocuments = useCallback(async () => { setLoading(true); setError(null); + try { - const res = await fetch(`${API_BASE}/api/documents?limit=200`, { - headers: getAuthHeaders(), - }); - if (!res.ok) throw new Error(`Failed to load documents (${res.status})`); - - const data = await res.json(); - const list = Array.isArray(data) ? data : (data?.data ?? []); - const located = list.filter( - (d: DocumentWithLocation) => d.latitude != null && d.longitude != null, - ); + const userId = await fetchCurrentUserId(); + if (!userId) { + throw new Error("Unable to verify your session."); + } + + const previousUserId = mapUserIdRef.current; + if (previousUserId && previousUserId !== userId) { + clearLastViewedParcel(previousUserId); + setDocs([]); + setSelectedParcelId(null); + setSidebarOpen(false); + setMapTotal(0); + setMapLoadedCount(0); + setMapLimited(false); + } + mapUserIdRef.current = userId; + + const loaded: unknown[] = []; + let total = 0; + let page = 1; + let limited = false; + const maxPages = Math.ceil(MAP_MAX_DOCUMENTS / MAP_PAGE_SIZE); + + while (page <= maxPages) { + const params = new URLSearchParams({ + page: String(page), + limit: String(MAP_PAGE_SIZE), + }); + const res = await fetch(apiUrl("/documents", params), { + credentials: "include", + headers: getAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`Failed to load documents (${res.status})`); + } + + const payload: unknown = await res.json(); + const pageData = Array.isArray(payload) + ? payload + : isRecord(payload) && Array.isArray(payload.data) + ? payload.data + : null; + if (!pageData) { + throw new Error("Invalid documents response"); + } + + const pageLimit = + isRecord(payload) && + typeof payload.limit === "number" && + Number.isFinite(payload.limit) + ? Math.max(1, payload.limit) + : MAP_PAGE_SIZE; + const remaining = Math.max(0, MAP_MAX_DOCUMENTS - loaded.length); + loaded.push(...pageData.slice(0, remaining)); + total = + isRecord(payload) && + typeof payload.total === "number" && + Number.isFinite(payload.total) + ? payload.total + : loaded.length; + + if ( + pageData.length < pageLimit || + loaded.length >= total || + loaded.length >= MAP_MAX_DOCUMENTS + ) { + limited = total > loaded.length; + break; + } + page += 1; + } + + const locatedFromList = loaded + .map(toDocumentWithLocation) + .filter((document): document is DocumentWithLocation => document !== null); + const missingLocationCount = loaded.length - locatedFromList.length; + let located = locatedFromList; + let lastViewedId = readLastViewedParcelId(userId); + + if ( + lastViewedId && + !located.some((document) => document.id === lastViewedId) + ) { + const lookup = await fetchDocumentById(lastViewedId); + if (lookup.kind === "found") { + located = [...located, lookup.document]; + } else if (lookup.kind === "missing") { + clearLastViewedParcel(userId); + lastViewedId = null; + } + } + setDocs(located); - // Documents without coordinates are excluded from the map but their - // count is still surfaced (FE-58). - setMissingLocationCount(Math.max(0, list.length - located.length)); + setMissingLocationCount(missingLocationCount); + setMapTotal(total); + setMapLoadedCount(loaded.length); + setMapLimited(limited || total > loaded.length); + + if (lastViewedId && located.some((document) => document.id === lastViewedId)) { + setSelectedParcelId(lastViewedId); + setSidebarOpen(true); + } else { + setSelectedParcelId(null); + setSidebarOpen(false); + } } catch (err) { setError( err instanceof Error ? err.message : "Failed to load documents.", @@ -114,6 +358,38 @@ export default function MapPageContent() { } }, []); + const selectedDocument = selectedParcelId + ? docs.find((document) => document.id === selectedParcelId) ?? null + : null; + const selectedLatitude = selectedDocument?.latitude; + const selectedLongitude = selectedDocument?.longitude; + + useEffect(() => { + if ( + loading || + !mapRef.current || + selectedLatitude == null || + selectedLongitude == null + ) { + return; + } + + mapRef.current.setView( + [selectedLatitude, selectedLongitude], + Math.max(mapRef.current.getZoom(), 13), + { animate: false }, + ); + }, [loading, selectedLatitude, selectedLongitude]); + + function handleSelectParcel(document: DocumentWithLocation) { + const userId = mapUserIdRef.current; + if (!userId) return; + + setSelectedParcelId(document.id); + setSidebarOpen(true); + saveLastViewedParcelId(document.id, userId); + } + function handleResetView() { if (!mapRef.current) return; if (docs.length > 0) { @@ -134,9 +410,11 @@ export default function MapPageContent() {

Document Map

- {docs.length > 0 - ? `Showing ${docs.length} document${docs.length !== 1 ? "s" : ""} with location data.` - : "Geographic view of land documents."} + {mapLimited + ? `Showing ${docs.length} mapped document${docs.length !== 1 ? "s" : ""}. Loaded ${mapLoadedCount} of ${mapTotal} records.` + : docs.length > 0 && mapTotal > 0 + ? `Showing ${docs.length} mapped document${docs.length !== 1 ? "s" : ""} from ${mapTotal} total records.` + : "Geographic view of land documents."}

@@ -252,6 +530,10 @@ export default function MapPageContent() { key={doc.id} position={[doc.latitude, doc.longitude]} icon={icon} + title={doc.title} + eventHandlers={{ + click: () => handleSelectParcel(doc), + }} >
@@ -265,7 +547,7 @@ export default function MapPageContent() { {doc.riskScore != null && (

- Risk: {doc.riskScore}/100 + Risk: {toRiskScore(doc.riskScore)}/100

)}
+ {sidebarOpen && selectedDocument && ( + setSidebarOpen(false)} + /> + )}
{error && ( @@ -295,6 +584,12 @@ export default function MapPageContent() { )} + {mapLimited && ( +

+ Map loading is capped at {mapLoadedCount} documents. Narrow the document list to inspect the remaining records. +

+ )} + {missingLocationCount > 0 && (

{missingLocationCount} document diff --git a/frontend/app/[locale]/(protected)/admin/documents/page.tsx b/frontend/app/[locale]/(protected)/admin/documents/page.tsx index 727f75e9..671f17da 100644 --- a/frontend/app/[locale]/(protected)/admin/documents/page.tsx +++ b/frontend/app/[locale]/(protected)/admin/documents/page.tsx @@ -4,6 +4,8 @@ import React, { useCallback, useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import { EmptyState } from "@/components/EmptyState"; +import { apiUrl } from "@/lib/api-config"; +import { getAccessToken } from "@/lib/session"; // --------------------------------------------------------------------------- // Types matching the backend Document entity + User owner @@ -41,8 +43,6 @@ interface PaginatedResponse { // Helpers // --------------------------------------------------------------------------- -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; - function riskColor(score?: number | null): string { if (score == null) return "text-gray-400"; if (score >= 0.7) return "text-red-600 font-semibold"; @@ -128,20 +128,15 @@ export default function AdminDocumentsPage() { if (f.dateTo) params.set("dateTo", f.dateTo); try { - const token = - typeof window !== "undefined" - ? localStorage.getItem("auth-token") - : null; - - const res = await fetch( - `${API_BASE}/api/admin/documents?${params.toString()}`, - { - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - } - ); + const token = getAccessToken(); + + const res = await fetch(apiUrl("/admin/documents", params), { + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); if (res.status === 403) { // FE-44 admin guard — non-admins must not see the listing. @@ -181,13 +176,11 @@ export default function AdminDocumentsPage() { const handleDownload = async (id: string, e: React.MouseEvent) => { e.stopPropagation(); - const token = - typeof window !== "undefined" - ? localStorage.getItem("auth-token") - : null; + const token = getAccessToken(); try { - const res = await fetch(`${API_BASE}/api/documents/${id}/export/pdf`, { + const res = await fetch(apiUrl(`/documents/${id}/export/pdf`), { + credentials: "include", headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) throw new Error("Download failed"); diff --git a/frontend/app/[locale]/(protected)/admin/layout.tsx b/frontend/app/[locale]/(protected)/admin/layout.tsx new file mode 100644 index 00000000..c0e18ad6 --- /dev/null +++ b/frontend/app/[locale]/(protected)/admin/layout.tsx @@ -0,0 +1,15 @@ +import { requireAdminSession } from "@/lib/admin-session"; + +export const dynamic = "force-dynamic"; + +export default async function LocalizedAdminLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + await requireAdminSession(locale); + return children; +} diff --git a/frontend/app/[locale]/(protected)/admin/providers/page.tsx b/frontend/app/[locale]/(protected)/admin/providers/page.tsx index 5cad2961..c41da106 100644 --- a/frontend/app/[locale]/(protected)/admin/providers/page.tsx +++ b/frontend/app/[locale]/(protected)/admin/providers/page.tsx @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; +import { apiUrl } from "@/lib/api-config"; +import { getAccessToken } from "@/lib/session"; import { AlertTriangle, Building2, @@ -41,11 +43,8 @@ const REFRESH_INTERVAL_SECONDS = 60; // Helpers // --------------------------------------------------------------------------- -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; - function getAuthHeaders(): HeadersInit { - const token = - typeof window !== "undefined" ? localStorage.getItem("auth-token") : null; + const token = getAccessToken(); return { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), @@ -261,34 +260,17 @@ export default function AdminProvidersPage() { const [checkErrors, setCheckErrors] = useState>>({}); const [secondsToRefresh, setSecondsToRefresh] = useState(REFRESH_INTERVAL_SECONDS); - // ── Admin access check ────────────────────────────────────────────────── - - useEffect(() => { - const token = localStorage.getItem("auth-token"); - if (!token) { - router.replace("/login"); - return; - } - try { - const payload = JSON.parse(atob(token.split(".")[1])); - if (payload?.role !== "admin") { - router.replace("/dashboard"); - } - } catch { - router.replace("/login"); - } - }, [router]); - // ── Fetch stats ────────────────────────────────────────────────────────── const fetchStats = useCallback(async () => { setError(null); try { - const res = await fetch(`${API_BASE}/api/external-validation/stats`, { + const res = await fetch(apiUrl("/external-validation/stats"), { + credentials: "include", 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}`); @@ -342,7 +324,8 @@ export default function AdminProvidersPage() { setCheckErrors((prev) => ({ ...prev, [id]: null })); try { - const res = await fetch(`${API_BASE}/api/external-validation/health`, { + const res = await fetch(apiUrl("/external-validation/health"), { + credentials: "include", headers: getAuthHeaders(), }); if (!res.ok) throw new Error(`Health check failed: ${res.status}`); diff --git a/frontend/app/[locale]/(protected)/admin/users/page.tsx b/frontend/app/[locale]/(protected)/admin/users/page.tsx index faf72619..86a1605a 100644 --- a/frontend/app/[locale]/(protected)/admin/users/page.tsx +++ b/frontend/app/[locale]/(protected)/admin/users/page.tsx @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; +import { apiUrl } from "@/lib/api-config"; +import { getAccessToken } from "@/lib/session"; // --------------------------------------------------------------------------- // Types @@ -31,11 +33,8 @@ interface PaginatedUsers { // Helpers // --------------------------------------------------------------------------- -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; - function getAuthHeaders(): HeadersInit { - const token = - typeof window !== "undefined" ? localStorage.getItem("auth-token") : null; + const token = getAccessToken(); return { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), @@ -45,7 +44,7 @@ function getAuthHeaders(): HeadersInit { function getCurrentUserId(): string | null { if (typeof window === "undefined") return null; try { - const token = localStorage.getItem("auth-token"); + const token = getAccessToken(); if (!token) return null; const payload = JSON.parse(atob(token.split(".")[1])); return payload?.sub ?? null; @@ -149,19 +148,6 @@ export default function AdminUsersPage() { const currentUserId = getCurrentUserId(); - // ── Check admin access ────────────────────────────────────────────────── - - useEffect(() => { - const token = localStorage.getItem("auth-token"); - if (!token) { router.replace("/login"); return; } - try { - const payload = JSON.parse(atob(token.split(".")[1])); - if (payload?.role !== "admin") { router.replace("/dashboard"); } - } catch { - router.replace("/login"); - } - }, [router]); - // ── Fetch users ───────────────────────────────────────────────────────── const fetchUsers = useCallback(async () => { @@ -176,10 +162,11 @@ export default function AdminUsersPage() { if (appliedSearch) params.set("search", appliedSearch); try { - const res = await fetch(`${API_BASE}/api/users?${params}`, { + const res = await fetch(apiUrl("/users", params), { + credentials: "include", 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); @@ -202,8 +189,9 @@ export default function AdminUsersPage() { } setActionLoading((prev) => ({ ...prev, [`role_${user.id}`]: true })); try { - const res = await fetch(`${API_BASE}/api/users/${user.id}`, { + const res = await fetch(apiUrl(`/users/${user.id}`), { method: "PATCH", + credentials: "include", headers: getAuthHeaders(), body: JSON.stringify({ role: newRole }), }); @@ -222,8 +210,9 @@ export default function AdminUsersPage() { const newStatus: UserStatus = user.status === "active" ? "suspended" : "active"; setActionLoading((prev) => ({ ...prev, [`status_${user.id}`]: true })); try { - const res = await fetch(`${API_BASE}/api/users/${user.id}`, { + const res = await fetch(apiUrl(`/users/${user.id}`), { method: "PATCH", + credentials: "include", headers: getAuthHeaders(), body: JSON.stringify({ status: newStatus }), }); @@ -243,8 +232,9 @@ export default function AdminUsersPage() { setDeleteLoading(true); setDeleteError(null); try { - const res = await fetch(`${API_BASE}/api/users/${deleteTarget.id}`, { + const res = await fetch(apiUrl(`/users/${deleteTarget.id}`), { method: "DELETE", + credentials: "include", headers: getAuthHeaders(), }); if (!res.ok) throw new Error("Delete failed"); @@ -265,8 +255,9 @@ export default function AdminUsersPage() { try { await Promise.all( Array.from(selected).map((id) => - fetch(`${API_BASE}/api/users/${id}`, { + fetch(apiUrl(`/users/${id}`), { method: "DELETE", + credentials: "include", headers: getAuthHeaders(), }) ) diff --git a/frontend/app/[locale]/(protected)/disputes/[id]/page.tsx b/frontend/app/[locale]/(protected)/disputes/[id]/page.tsx index e0c26bd8..7893eb72 100644 --- a/frontend/app/[locale]/(protected)/disputes/[id]/page.tsx +++ b/frontend/app/[locale]/(protected)/disputes/[id]/page.tsx @@ -1,76 +1,75 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; -import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; import { useParams } from "next/navigation"; +import { Link } from "@/i18n/navigation"; +import { useDateFormatting } from "@/i18n/formatting"; import { request } from "@/lib/api-client"; import { useToast } from "@/components/ui/use-toast"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- +type DisputeStatus = "open" | "in_review" | "resolved" | "dismissed"; -type DisputeStatus = "OPEN" | "UNDER_REVIEW" | "RESOLVED" | "REJECTED"; +type DisputeReason = { + id: string; + name: string; +}; -interface Dispute { +type Dispute = { id: string; documentId: string; description: string; - reason: string | null; + reason: DisputeReason | null; status: DisputeStatus; filedBy: string; createdAt: string; - timeline: { status: DisputeStatus; createdAt: string }[]; - resolution?: string | null; - resolvedAt?: string | null; - document: { - title: string; - status: string; - riskScore: number; - }; -} +}; -interface User { +type User = { id: string; - role: "USER" | "ADMIN"; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- + role: "user" | "admin"; +}; const STATUS_CLASSES: Record = { - OPEN: "bg-blue-100 text-blue-800", - UNDER_REVIEW: "bg-yellow-100 text-yellow-800", - RESOLVED: "bg-green-100 text-green-800", - REJECTED: "bg-red-100 text-red-800", + open: "bg-blue-100 text-blue-800", + in_review: "bg-yellow-100 text-yellow-800", + resolved: "bg-green-100 text-green-800", + dismissed: "bg-red-100 text-red-800", }; -// --------------------------------------------------------------------------- -// Page -// --------------------------------------------------------------------------- +const NEXT_STATUSES: Record = { + open: ["in_review", "dismissed"], + in_review: ["resolved", "dismissed"], + resolved: [], + dismissed: [], +}; + +function formatStatus(status: DisputeStatus): string { + return status.replace("_", " "); +} export default function DisputeDetailPage() { const params = useParams<{ id: string }>(); const disputeId = params.id; const { toast } = useToast(); - + const { formatDate } = useDateFormatting(); const [dispute, setDispute] = useState(null); - const [user, setUser] = useState(null); // Assume we get user info from an auth hook + const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [adminStatus, setAdminStatus] = useState("OPEN"); - const [adminResolution, setAdminResolution] = useState(""); + const [adminStatus, setAdminStatus] = useState("open"); const fetchDispute = useCallback(async () => { if (!disputeId) return; + setLoading(true); + setError(null); try { - const disputeData = await request(`/api/disputes/${disputeId}`); + const [disputeData, userData] = await Promise.all([ + request(`/disputes/${disputeId}`), + request("/auth/me"), + ]); setDispute(disputeData); setAdminStatus(disputeData.status); - // In a real app, user data would come from a context or hook - const userData = await request("/api/users/me"); setUser(userData); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load dispute."); @@ -80,40 +79,23 @@ export default function DisputeDetailPage() { }, [disputeId]); useEffect(() => { - fetchDispute(); + void fetchDispute(); }, [fetchDispute]); - const handleWithdrawDispute = async () => { - if (!disputeId) return; - try { - await request(`/api/disputes/${disputeId}`, { method: "DELETE" }); - toast({ - title: "Dispute Withdrawn", - description: "The dispute has been successfully withdrawn.", - }); - // Redirect or update UI - } catch (err) { - toast({ - title: "Error", - description: "Failed to withdraw the dispute.", - variant: "destructive", - }); - } - }; - const handleAdminUpdate = async () => { - if (!disputeId) return; + if (!disputeId || !dispute || !user || user.role !== "admin") return; + try { - await request(`/api/disputes/${disputeId}`, { + await request(`/disputes/${disputeId}/status`, { method: "PATCH", - body: { status: adminStatus, resolution: adminResolution }, + body: { status: adminStatus }, }); toast({ - title: "Dispute Updated", - description: "The dispute has been successfully updated.", + title: "Dispute updated", + description: "The dispute status has been updated.", }); - fetchDispute(); // Refetch to show updated data - } catch (err) { + await fetchDispute(); + } catch { toast({ title: "Error", description: "Failed to update the dispute.", @@ -134,23 +116,22 @@ export default function DisputeDetailPage() { if (!dispute) { return (

-

{error ?? "Dispute not found."}

- +

+ {error ?? "Dispute not found."} +

+ Back to disputes
); } + const allowedAdminStatuses = NEXT_STATUSES[dispute.status]; + const canUpdate = user?.role === "admin" && allowedAdminStatuses.length > 0; + return (
- + ← Back to disputes @@ -158,156 +139,67 @@ export default function DisputeDetailPage() {

Dispute #{dispute.id.substring(0, 8)}

- - {dispute.status.replace("_", " ")} + + {formatStatus(dispute.status)}

- Filed on {new Date(dispute.createdAt).toLocaleDateString()} + Filed on {dispute.createdAt ? formatDate(dispute.createdAt) : "—"}

-

- Disputed Document -

+

Disputed document

- - {dispute.document.title} + + {dispute.documentId}

-
- Status: {dispute.document.status} - Risk Score: {dispute.document.riskScore}% -
-

- Reason for Dispute -

+

Reason for dispute

{dispute.description}

{dispute.reason && (

- Classified as: {dispute.reason} + Classified as: {dispute.reason.name}

)}
-
-

- Status Timeline -

-
    - {dispute.timeline.map((event, index) => ( -
  1. -
  2. - ))} -
-
- - {(dispute.status === "RESOLVED" || dispute.status === "REJECTED") && - dispute.resolution && ( -
-

- Resolution -

-

- {dispute.resolution} -

-

- Resolved on {new Date(dispute.resolvedAt!).toLocaleDateString()} -

-
- )} - - {user?.role === "ADMIN" && ( + {canUpdate && (
-

- Admin Controls -

-
+

Admin controls

+
-
-
- -