diff --git a/backend/.env.example b/backend/.env.example index eed8938e..4542856c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,8 @@ AUTH_COOKIE_DOMAIN= APP_PORT=3001 APP_URL=http://localhost:3001 FRONTEND_URL=http://localhost:3000 +# Production cross-host browser sessions require a shared parent domain. +# SESSION_COOKIE_DOMAIN=example.com LOG_LEVEL=info # Google OAuth Configuration diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 7d4b66f9..448c4352 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 a verified token to the blacklist', async () => { mockJwtService.verifyAsync.mockResolvedValueOnce({ diff --git a/backend/src/config/config.validation.ts b/backend/src/config/config.validation.ts index 20d55fa4..ba91c07f 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,7 @@ 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, // ── Database ─────────────────────────────────────────────────────────────── DATABASE_HOST: Joi.string().required(), diff --git a/backend/src/dispute/dispute.controller.ts b/backend/src/dispute/dispute.controller.ts index ecf20eef..8f1ff0c1 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 5032f0b3..4b694428 100644 --- a/backend/src/documents/documents.controller.ts +++ b/backend/src/documents/documents.controller.ts @@ -143,6 +143,7 @@ export class DocumentsController { query.page!, query.limit!, query.status, + query.search, ); return { @@ -291,13 +292,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/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 10e66350..d06fadb3 100644 --- a/frontend/app/[locale]/(protected)/admin/documents/page.tsx +++ b/frontend/app/[locale]/(protected)/admin/documents/page.tsx @@ -5,6 +5,8 @@ import { useTranslations } from "next-intl"; import { FileText } from "lucide-react"; 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 @@ -42,8 +44,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"; @@ -129,20 +129,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. @@ -182,13 +177,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)/documents/page.tsx b/frontend/app/[locale]/(protected)/documents/page.tsx index 6c04b6ed..b472a4fe 100644 --- a/frontend/app/[locale]/(protected)/documents/page.tsx +++ b/frontend/app/[locale]/(protected)/documents/page.tsx @@ -1,199 +1,405 @@ "use client"; -import React, { useState, useEffect } from "react"; -import Link from "next/link"; +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; +import { useDateFormatting } from "@/i18n/formatting"; +import { request } from "@/lib/api-client"; +import { apiUrl } from "@/lib/api-config"; + +type DocumentStatus = + | "pending" + | "analyzing" + | "verified" + | "flagged" + | "rejected"; +type StatusFilter = "all" | DocumentStatus; interface DocumentItem { id: string; title: string; - filename: string; - status: "PENDING" | "ANALYZING" | "VERIFIED" | "FLAGGED" | "REJECTED"; + status: DocumentStatus; + riskScore: number | null; + riskFlags: string[] | null; createdAt: string; - fileSize: string; + updatedAt: string; + fileSize?: number; +} + +interface PaginatedDocuments { + documents: DocumentItem[]; + total: number; + page: number; + limit: number; } -const STATUS_BADGE_CLASSES: Record = { - PENDING: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30", - ANALYZING: "bg-blue-500/10 text-blue-400 border-blue-500/30", - VERIFIED: "bg-green-500/10 text-green-400 border-green-500/30", - FLAGGED: "bg-orange-500/10 text-orange-400 border-orange-500/30", - REJECTED: "bg-red-500/10 text-red-400 border-red-500/30", +const PAGE_SIZE = 5; +const MAX_DOCUMENT_LIMIT = 100; +const MAX_DOCUMENT_PAGE = 10000; +const DOCUMENT_STATUSES: DocumentStatus[] = [ + "pending", + "analyzing", + "verified", + "flagged", + "rejected", +]; + +const STATUS_BADGE_CLASSES: Record = { + pending: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30", + analyzing: "bg-blue-500/10 text-blue-400 border-blue-500/30", + verified: "bg-green-500/10 text-green-400 border-green-500/30", + flagged: "bg-orange-500/10 text-orange-400 border-orange-500/30", + rejected: "bg-red-500/10 text-red-400 border-red-500/30", }; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function normalizeStatus(value: unknown): DocumentStatus { + const status = typeof value === "string" ? value.toLowerCase() : ""; + return DOCUMENT_STATUSES.includes(status as DocumentStatus) + ? (status as DocumentStatus) + : "pending"; +} + +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 parseDocumentsResponse(payload: unknown): PaginatedDocuments { + if (!isRecord(payload) || !Array.isArray(payload.data)) { + throw new Error("Invalid documents response"); + } + + const documents = payload.data.map((item) => { + if ( + !isRecord(item) || + typeof item.id !== "string" || + typeof item.title !== "string" + ) { + throw new Error("Invalid document response"); + } + + return { + id: item.id, + title: item.title, + status: normalizeStatus(item.status), + riskScore: toNullableNumber(item.riskScore), + riskFlags: Array.isArray(item.riskFlags) + ? item.riskFlags.filter((flag): flag is string => typeof flag === "string") + : null, + createdAt: typeof item.createdAt === "string" ? item.createdAt : "", + updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : "", + fileSize: toNullableNumber(item.fileSize) ?? undefined, + } satisfies DocumentItem; + }); + + return { + documents, + total: + typeof payload.total === "number" && Number.isFinite(payload.total) + ? payload.total + : documents.length, + page: Math.min( + MAX_DOCUMENT_PAGE, + Math.max( + 1, + typeof payload.page === "number" && Number.isFinite(payload.page) + ? payload.page + : 1, + ), + ), + limit: Math.min( + MAX_DOCUMENT_LIMIT, + Math.max( + 1, + typeof payload.limit === "number" && Number.isFinite(payload.limit) + ? payload.limit + : PAGE_SIZE, + ), + ), + }; +} + +function formatFileSize( + value: number | undefined, + formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string, +): string { + if (value == null || value < 0) return "—"; + if (value < 1024) return `${formatNumber(value)} B`; + if (value < 1024 * 1024) { + return `${formatNumber(value / 1024, { maximumFractionDigits: 1 })} KB`; + } + return `${formatNumber(value / (1024 * 1024), { maximumFractionDigits: 1 })} MB`; +} + export default function DocumentsListPage() { + const t = useTranslations("documents"); + const tCommon = useTranslations("common"); + const tErrors = useTranslations("errors"); + const { formatDate, formatNumber } = useDateFormatting(); const [documents, setDocuments] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState("ALL"); - const [loading, setLoading] = useState(true); - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 5; + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(PAGE_SIZE); - useEffect(() => { - async function fetchDocuments() { + const fetchDocuments = useCallback( + async (signal?: AbortSignal) => { setLoading(true); + setError(null); + setDocuments([]); + setTotal(0); + + const requestedPage = Math.min( + MAX_DOCUMENT_PAGE, + Math.max(1, currentPage), + ); + const params = new URLSearchParams({ + page: String(requestedPage), + limit: String(PAGE_SIZE), + }); + const search = searchQuery.trim(); + if (search) params.set("search", search); + if (statusFilter !== "all") params.set("status", statusFilter); + try { - const res = await fetch("/api/documents"); - if (res.ok) { - const data = await res.json(); - setDocuments(data.documents || []); - } else { - // Fallback mock dataset for demonstration if API endpoint is unpopulated - setDocuments([ - { id: "doc-1", title: "Financial Statement Q2", filename: "financial_q2.pdf", status: "VERIFIED", createdAt: "2026-07-28", fileSize: "1.2 MB" }, - { id: "doc-2", title: "Identity Verification Document", filename: "passport_scan.png", status: "ANALYZING", createdAt: "2026-07-29", fileSize: "3.4 MB" }, - { id: "doc-3", title: "Tax Exemption Form", filename: "tax_form_2025.pdf", status: "PENDING", createdAt: "2026-07-29", fileSize: "850 KB" }, - { id: "doc-4", title: "Compliance Report 2025", filename: "compliance_2025.pdf", status: "FLAGGED", createdAt: "2026-07-27", fileSize: "4.1 MB" }, - { id: "doc-5", title: "Outdated License Copy", filename: "old_license.jpeg", status: "REJECTED", createdAt: "2026-07-25", fileSize: "2.0 MB" }, - ]); + const payload = await request( + apiUrl("/documents", params), + { signal }, + ); + if (signal?.aborted) return; + + const result = parseDocumentsResponse(payload); + const lastPage = Math.max(1, Math.ceil(result.total / result.limit)); + if (result.page !== currentPage) { + setCurrentPage(result.page); + return; + } + if (result.total > 0 && currentPage > lastPage) { + setCurrentPage(lastPage); + return; } + + setDocuments(result.documents); + setTotal(result.total); + setPageSize(result.limit); } catch { - setDocuments([]); + if (signal?.aborted) return; + setError(t("loadError")); } finally { - setLoading(false); + if (!signal?.aborted) setLoading(false); } - } - fetchDocuments(); - }, []); - - const filteredDocuments = documents.filter((doc) => { - const matchesSearch = doc.title.toLowerCase().includes(searchQuery.toLowerCase()) || - doc.filename.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesStatus = statusFilter === "ALL" || doc.status === statusFilter; - return matchesSearch && matchesStatus; - }); - - const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage) || 1; - const paginatedDocuments = filteredDocuments.slice( - (currentPage - 1) * itemsPerPage, - currentPage * itemsPerPage + }, + [currentPage, searchQuery, statusFilter, t], ); + useEffect(() => { + const controller = new AbortController(); + void fetchDocuments(controller.signal); + return () => controller.abort(); + }, [fetchDocuments]); + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const firstResult = total === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const lastResult = total === 0 ? 0 : Math.min(currentPage * pageSize, total); + return (

-

Document Management

-

View, search, and manage your verified documents.

+

{t("title")}

+

+ {t("subtitle")} +

- + Upload New Document + {t("upload")}
- {/* Search and Filters */} -
- { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - placeholder="Search documents by title..." - className="w-full rounded-md border border-gray-800 bg-gray-900 px-3 py-2 text-sm text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none sm:w-72" - /> +
+
+ + { + setSearchQuery(event.target.value); + setCurrentPage(1); + }} + placeholder={t("search.placeholder")} + className="w-full rounded-md border border-gray-800 bg-gray-900 px-3 py-2 text-sm text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none" + /> +
- Status: +
-
+ - {/* Documents Table */} -
+
{loading ? ( -
-
-

Loading document list...

+
+ - ) : paginatedDocuments.length === 0 ? ( + ) : error ? ( +
+

{error}

+ +
+ ) : documents.length === 0 ? (
- No documents found matching your filter criteria. + {t("empty")}
) : ( - - - - - - - - - - - - {paginatedDocuments.map((doc) => ( - - - - - - +
+
TitleStatusFile SizeUploaded DateAction
- - {doc.title} - -
{doc.filename}
-
- - {doc.status} - - {doc.fileSize}{doc.createdAt} - - View Details → - -
+ + + + + + + - ))} - -
+ {t("table.name")} + + {t("table.status")} + + {t("table.fileSize")} + + {t("table.uploaded")} + + {t("table.action")} +
+ + + {documents.map((document) => ( + + + + {document.title} + + + + + {t(`status.${document.status}`)} + + + + {formatFileSize(document.fileSize, formatNumber)} + + + {document.createdAt ? formatDate(document.createdAt) : "—"} + + + + {t("table.view")} + + + + ))} + + +
)} - {/* Pagination Footer */} -
- - Page {currentPage} of {totalPages} - -
- - -
-
+ {!loading && !error && total > 0 && ( + + )}
); diff --git a/frontend/app/[locale]/(protected)/settings/data/page.tsx b/frontend/app/[locale]/(protected)/settings/data/page.tsx index 4f85fc6e..80693074 100644 --- a/frontend/app/[locale]/(protected)/settings/data/page.tsx +++ b/frontend/app/[locale]/(protected)/settings/data/page.tsx @@ -212,7 +212,7 @@ export default function SettingsDataPage() { body: { password }, }); - if (!(await logout())) { + if (!logout()) { throw new Error("Sign out failed. Please try again."); } router.push("/?deleted=true"); diff --git a/frontend/app/api/admin-unavailable/route.ts b/frontend/app/api/admin-unavailable/route.ts new file mode 100644 index 00000000..88e210ee --- /dev/null +++ b/frontend/app/api/admin-unavailable/route.ts @@ -0,0 +1,9 @@ +export function GET() { + return Response.json( + { error: "Admin access is temporarily unavailable" }, + { + status: 503, + headers: { "Cache-Control": "no-store" }, + }, + ); +} diff --git a/frontend/app/auth/oauth/callback/page.tsx b/frontend/app/auth/oauth/callback/page.tsx new file mode 100644 index 00000000..b7f26f0d --- /dev/null +++ b/frontend/app/auth/oauth/callback/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { apiRequest } from "@/lib/api-client"; +import { storeSession, type LoginResponse } from "@/lib/auth-session"; + +function OAuthCallbackContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const code = searchParams.get("code"); + const [error, setError] = useState(null); + + useEffect(() => { + if (!code) { + setError("The sign-in exchange code is missing."); + return; + } + + let active = true; + window.history.replaceState({}, "", window.location.pathname); + + void apiRequest("/auth/oauth/exchange", { + method: "POST", + anonymous: true, + credentials: "include", + body: { code }, + }) + .then((tokens) => { + if (!active) return; + storeSession(tokens); + router.replace("/"); + }) + .catch(() => { + if (!active) return; + setError("The sign-in exchange expired. Please try again."); + }); + + return () => { + active = false; + }; + }, [code, router]); + + return ( +
+
+

Completing sign in

+ {error ? ( + <> +

+ {error} +

+ + Return to login + + + ) : ( +

+ Establishing your session… +

+ )} +
+
+ ); +} + +export default function OAuthCallbackPage() { + return ( + +

+ Completing sign in… +

+ + } + > + +
+ ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 0fbfe91f..9ccea7e5 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -6,6 +6,7 @@ import { MAIN_CONTENT_ID } from "@/lib/main-content"; import { SkipToContentLink } from "@/components/layout/SkipToContentLink"; import { ToastProvider } from "@/components/ui/use-toast"; import { Toaster } from "@/components/ui/toast"; +import { SessionSync } from "@/components/SessionSync"; import "./globals.css"; const geistSans = Geist({ @@ -39,6 +40,7 @@ export default function RootLayout({ +
{children}
diff --git a/frontend/components/SessionSync.tsx b/frontend/components/SessionSync.tsx new file mode 100644 index 00000000..7d85b4b5 --- /dev/null +++ b/frontend/components/SessionSync.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useEffect } from "react"; +import { initCrossTabLogoutSync } from "@/lib/session-expiry-warning"; + +export function SessionSync() { + useEffect(() => initCrossTabLogoutSync(), []); + return null; +} diff --git a/frontend/components/disputes/FileDisputeModal.tsx b/frontend/components/disputes/FileDisputeModal.tsx index 7cf9caca..9df08918 100644 --- a/frontend/components/disputes/FileDisputeModal.tsx +++ b/frontend/components/disputes/FileDisputeModal.tsx @@ -11,6 +11,15 @@ import { } from "@/lib/schemas/dispute"; import type { DocumentListItem } from "@/lib/schemas/document"; import { useToast } from "@/components/ui/use-toast"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; type Document = Pick; @@ -75,11 +84,19 @@ export function FileDisputeModal({ }; return ( -
-
-

- File a New Dispute -

+ { + if (!nextOpen) onClose(); + }} + > + + + File a New Dispute + + Submit a dispute against one of your documents. + +
- {error &&

{error}

} -
- + {error &&

{error}

} + + + + -
+
-
-
+ + ); } diff --git a/frontend/components/disputes/__tests__/FileDisputeModal.test.tsx b/frontend/components/disputes/__tests__/FileDisputeModal.test.tsx new file mode 100644 index 00000000..ed11c41b --- /dev/null +++ b/frontend/components/disputes/__tests__/FileDisputeModal.test.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FileDisputeModal } from "../FileDisputeModal"; + +const mockRequest = jest.fn(); +const mockToast = jest.fn(); + +jest.mock("@/lib/api-client", () => ({ + request: (...args: unknown[]) => mockRequest(...args), +})); + +jest.mock("@/components/ui/use-toast", () => ({ + useToast: () => ({ toast: mockToast }), +})); + +function renderModal() { + const onClose = jest.fn(); + const onDisputeFiled = jest.fn(); + render( + , + ); + return { onClose, onDisputeFiled }; +} + +describe("FileDisputeModal", () => { + beforeEach(() => { + mockRequest.mockReset(); + mockToast.mockReset(); + }); + + it("uses dialog semantics and keeps focus inside the modal", async () => { + renderModal(); + const dialog = await screen.findByRole("dialog", { + name: "File a New Dispute", + }); + const closeButton = screen.getByRole("button", { name: "Cancel" }); + closeButton.focus(); + + await userEvent.tab(); + + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toContainElement(document.activeElement as HTMLElement); + }); + + it("closes on Escape", async () => { + const { onClose } = renderModal(); + await screen.findByRole("dialog", { name: "File a New Dispute" }); + + fireEvent.keyDown(document, { key: "Escape", code: "Escape" }); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it("unwraps a dispute data envelope before notifying the page", async () => { + const dispute = { + id: "dispute-1", + documentId: "doc-1", + description: "A sufficiently detailed dispute description.", + reason: { id: "reason-1", name: "Ownership" }, + filedBy: "user-1", + status: "open" as const, + createdAt: "2026-07-28T00:00:00.000Z", + }; + mockRequest.mockResolvedValue({ data: dispute }); + const { onDisputeFiled, onClose } = renderModal(); + await screen.findByRole("dialog", { name: "File a New Dispute" }); + + fireEvent.change(screen.getByLabelText("Select Document"), { + target: { value: "doc-1" }, + }); + fireEvent.change(screen.getByLabelText("Reason for Dispute"), { + target: { value: dispute.description }, + }); + fireEvent.click(screen.getByRole("button", { name: "Submit Dispute" })); + + await waitFor(() => expect(onDisputeFiled).toHaveBeenCalledWith(dispute)); + expect(mockRequest).toHaveBeenCalledWith( + "http://localhost:3001/api/v1/disputes", + expect.objectContaining({ method: "POST" }), + ); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/components/layout/NotificationBell.tsx b/frontend/components/layout/NotificationBell.tsx index a7500676..97a06a52 100644 --- a/frontend/components/layout/NotificationBell.tsx +++ b/frontend/components/layout/NotificationBell.tsx @@ -3,8 +3,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "@/i18n/navigation"; import Link from "next/link"; +import { apiUrl } from "@/lib/api-config"; +import { getAccessToken } from "@/lib/session"; -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; const WS_BASE = ( process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:3001" ).replace(/^http/, "ws"); @@ -31,8 +32,7 @@ const NOTIFICATION_ICONS: Record = { }; function getAuthHeaders(): HeadersInit { - const token = - typeof window !== "undefined" ? localStorage.getItem("auth-token") : null; + const token = getAccessToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } @@ -83,10 +83,12 @@ export default function NotificationBell() { setLoading(true); try { const [listRes, countRes] = await Promise.all([ - fetch(`${API_BASE}/api/notifications?limit=5`, { + fetch(apiUrl("/notifications", { limit: 5 }), { + credentials: "include", headers: getAuthHeaders(), }), - fetch(`${API_BASE}/api/notifications/unread-count`, { + fetch(apiUrl("/notifications/unread-count"), { + credentials: "include", headers: getAuthHeaders(), }), ]); @@ -159,8 +161,9 @@ export default function NotificationBell() { async function handleMarkAllRead() { try { - await fetch(`${API_BASE}/api/notifications/read-all`, { + await fetch(apiUrl("/notifications/read-all"), { method: "PATCH", + credentials: "include", headers: { ...getAuthHeaders(), "Content-Type": "application/json" }, }); setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); diff --git a/frontend/components/ui/__tests__/dialog.test.tsx b/frontend/components/ui/__tests__/dialog.test.tsx index 6fcce10e..d0270cb7 100644 --- a/frontend/components/ui/__tests__/dialog.test.tsx +++ b/frontend/components/ui/__tests__/dialog.test.tsx @@ -1,5 +1,6 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { Dialog, DialogTrigger, @@ -59,6 +60,18 @@ describe("Dialog", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + it("keeps focus inside the open dialog", async () => { + render(); + open(); + const dialog = await screen.findByRole("dialog"); + const closeButton = screen.getByRole("button", { name: "Cancel" }); + closeButton.focus(); + + await userEvent.tab(); + + expect(dialog).toContainElement(document.activeElement as HTMLElement); + }); + it("closes when DialogClose is activated", async () => { render(); open(); diff --git a/frontend/lib/admin-session.ts b/frontend/lib/admin-session.ts new file mode 100644 index 00000000..16a85fa4 --- /dev/null +++ b/frontend/lib/admin-session.ts @@ -0,0 +1,95 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { apiUrl } from "@/lib/api-config"; + +const SESSION_COOKIE = "smalda_access_token"; +const ADMIN_CHECK_TIMEOUT_MS = 5000; + +export interface ServerAdminSession { + id: string; + email: string; + fullName: string; + role: "admin"; +} + +type SessionCheck = + | { kind: "admin"; session: ServerAdminSession } + | { kind: "unauthenticated" } + | { kind: "forbidden" } + | { kind: "unavailable" }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +async function checkAdminSession(): Promise { + let token: string | undefined; + try { + const cookieStore = await cookies(); + token = cookieStore.get(SESSION_COOKIE)?.value; + } catch { + return { kind: "unavailable" }; + } + if (!token) return { kind: "unauthenticated" }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ADMIN_CHECK_TIMEOUT_MS); + + try { + const response = await fetch(apiUrl("/auth/me"), { + method: "GET", + headers: { + Accept: "application/json", + Cookie: `${SESSION_COOKIE}=${encodeURIComponent(token)}`, + }, + cache: "no-store", + signal: controller.signal, + }); + + if (response.status === 401) return { kind: "unauthenticated" }; + if (response.status === 403) return { kind: "forbidden" }; + if (!response.ok) return { kind: "unavailable" }; + + const body: unknown = await response.json(); + if ( + !isRecord(body) || + body.role !== "admin" || + typeof body.id !== "string" || + typeof body.email !== "string" || + typeof body.fullName !== "string" + ) { + return { kind: "unavailable" }; + } + + return { + kind: "admin", + session: { + id: body.id, + email: body.email, + fullName: body.fullName, + role: "admin", + }, + }; + } catch { + return { kind: "unavailable" }; + } finally { + clearTimeout(timeout); + } +} + +export async function requireAdminSession(locale: string): Promise { + const result = await checkAdminSession(); + + if (result.kind === "admin") return result.session; + + if (result.kind === "unauthenticated") { + const loginPath = locale === "en" ? "/login" : `/${locale}/login`; + redirect(`${loginPath}?redirect=${encodeURIComponent("/admin")}`); + } + + if (result.kind === "forbidden") { + redirect(locale === "en" ? "/" : `/${locale}/`); + } + + redirect("/api/admin-unavailable"); +} diff --git a/frontend/lib/api-config.ts b/frontend/lib/api-config.ts new file mode 100644 index 00000000..e6788d29 --- /dev/null +++ b/frontend/lib/api-config.ts @@ -0,0 +1,45 @@ +const DEFAULT_API_ORIGIN = "http://localhost:3001"; +const API_VERSION_PREFIX = "/api/v1"; + +export function normalizeConfiguredBase(value: string): string { + const base = value.trim().replace(/\/+$/, ""); + if (!base) return `${DEFAULT_API_ORIGIN}${API_VERSION_PREFIX}`; + if (/\/api\/v1$/i.test(base)) return base; + if (/\/api$/i.test(base)) return `${base}/v1`; + return `${base}${API_VERSION_PREFIX}`; +} + +export const API_BASE = normalizeConfiguredBase( + process.env.NEXT_PUBLIC_API_URL ?? DEFAULT_API_ORIGIN, +); + +export function normalizeResourcePath(path: string): string { + let normalized = path.trim(); + if (!normalized) return "/"; + if (!normalized.startsWith("/")) normalized = `/${normalized}`; + normalized = normalized.replace(/^\/api\/v1(?=\/|$)/i, ""); + normalized = normalized.replace(/^\/api(?=\/|$)/i, ""); + return normalized || "/"; +} + +function toQueryString( + params?: URLSearchParams | Record, +): string { + if (!params) return ""; + const query = params instanceof URLSearchParams ? params : new URLSearchParams(); + if (!(params instanceof URLSearchParams)) { + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined) query.set(key, String(value)); + } + } + const value = query.toString(); + return value ? `?${value}` : ""; +} + +export function apiUrl( + path: string, + params?: URLSearchParams | Record, +): string { + if (/^https?:\/\//i.test(path)) return path; + return `${API_BASE}${normalizeResourcePath(path)}${toQueryString(params)}`; +} diff --git a/frontend/lib/map-state.ts b/frontend/lib/map-state.ts new file mode 100644 index 00000000..112918f1 --- /dev/null +++ b/frontend/lib/map-state.ts @@ -0,0 +1,66 @@ +export const LAST_VIEWED_PARCEL_KEY = "smalda:last-viewed-parcel"; + +const USER_KEY_PREFIX = `${LAST_VIEWED_PARCEL_KEY}:`; + +function getStorage(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.sessionStorage; + } catch { + return null; + } +} + +function getUserKey(userId: string): string | null { + if (!userId || userId.length > 200) return null; + return `${USER_KEY_PREFIX}${userId}`; +} + +export function readLastViewedParcelId(userId?: string): string | null { + const storage = getStorage(); + const key = getUserKey(userId); + if (!storage || !key) return null; + + try { + const value = storage.getItem(key); + if (!value || value.length > 200) return null; + return value; + } catch { + return null; + } +} + +export function saveLastViewedParcelId(id: string, userId?: string): void { + const storage = getStorage(); + const key = getUserKey(userId); + if (!storage || !key || !id || id.length > 200) return; + + try { + storage.setItem(key, id); + } catch { + return; + } +} + +export function clearLastViewedParcel(userId?: string): void { + const storage = getStorage(); + if (!storage) return; + + try { + if (userId) { + const key = getUserKey(userId); + if (key) storage.removeItem(key); + return; + } + + storage.removeItem(LAST_VIEWED_PARCEL_KEY); + const keys: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key?.startsWith(USER_KEY_PREFIX)) keys.push(key); + } + keys.forEach((key) => storage.removeItem(key)); + } catch { + return; + } +} diff --git a/frontend/lib/session-expiry-warning.ts b/frontend/lib/session-expiry-warning.ts index 3f5f131b..31db627e 100644 --- a/frontend/lib/session-expiry-warning.ts +++ b/frontend/lib/session-expiry-warning.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import { clearSession } from "@/lib/api-client"; +import { clearLastViewedParcel } from "@/lib/map-state"; const ACCESS_TOKEN_KEY = "auth-token"; const WARNING_BEFORE_MS = 5 * 60 * 1000; @@ -81,6 +82,7 @@ export function initCrossTabLogoutSync(): () => void { function handler(event: StorageEvent) { if (event.key === "logout-event" && event.newValue) { clearSession({ notify: false }); + clearLastViewedParcel(); window.location.href = "/login"; } } diff --git a/frontend/lib/session.ts b/frontend/lib/session.ts new file mode 100644 index 00000000..1eb39a6c --- /dev/null +++ b/frontend/lib/session.ts @@ -0,0 +1,113 @@ +import { apiUrl } from "@/lib/api-config"; +import { clearLastViewedParcel } from "@/lib/map-state"; + +export const ACCESS_TOKEN_KEY = "auth-token"; +export const REFRESH_TOKEN_KEY = "auth-refresh-token"; +export const LEGACY_REFRESH_TOKEN_KEY = "refresh-token"; + +export function getAccessToken(): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(ACCESS_TOKEN_KEY); + } catch { + return null; + } +} + +export function getRefreshToken(): string | null { + if (typeof window === "undefined") return null; + try { + return ( + window.localStorage.getItem(REFRESH_TOKEN_KEY) ?? + window.localStorage.getItem(LEGACY_REFRESH_TOKEN_KEY) + ); + } catch { + return null; + } +} + +export function setAccessToken(token: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(ACCESS_TOKEN_KEY, token); + } catch { + return; + } +} + +export function setRefreshToken(token: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(REFRESH_TOKEN_KEY, token); + } catch { + return; + } +} + +function clearCookie(name: string): void { + if (typeof document === "undefined") return; + const secure = window.location.protocol === "https:"; + const attributes = secure + ? "Max-Age=0; Path=/; SameSite=None; Secure" + : "Max-Age=0; Path=/; SameSite=Lax"; + try { + document.cookie = `${name}=; ${attributes}`; + } catch { + return; + } +} + +export function clearLocalSession(): void { + if (typeof window === "undefined") return; + + try { + window.localStorage.removeItem(ACCESS_TOKEN_KEY); + window.localStorage.removeItem(REFRESH_TOKEN_KEY); + window.localStorage.removeItem(LEGACY_REFRESH_TOKEN_KEY); + window.localStorage.setItem("logout-event", Date.now().toString()); + } catch (error) { + void error; + } + + clearCookie("smalda_access_token"); + clearCookie("auth-token"); + clearCookie("token"); + clearLastViewedParcel(); +} + +async function revokeBackendSession( + token: string | null, + refreshToken: string | null, +): Promise { + if (typeof window === "undefined") return; + + try { + const headers: HeadersInit = {}; + if (token) headers.Authorization = `Bearer ${token}`; + if (refreshToken) headers["Content-Type"] = "application/json"; + + await fetch(apiUrl("/auth/logout"), { + method: "POST", + credentials: "include", + headers, + ...(refreshToken ? { body: JSON.stringify({ refreshToken }) } : {}), + }); + } catch (error) { + void error; + } +} + +export async function clearSession(): Promise { + const token = getAccessToken(); + const refreshToken = getRefreshToken(); + + try { + await revokeBackendSession(token, refreshToken); + } finally { + clearLocalSession(); + } +} + +export async function logoutSession(): Promise { + await clearSession(); +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ab243048..6eb1c201 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -161,6 +161,8 @@ }, "documents": { "title": "All Documents", + "subtitle": "View, search, and manage your verified documents.", + "upload": "Upload New Document", "filters": { "legend": "Document filters", "status": "Status", @@ -173,6 +175,10 @@ "apply": "Apply filters", "reset": "Reset" }, + "search": { + "label": "Search documents", + "placeholder": "Search documents by title..." + }, "status": { "pending": "Pending", "analyzing": "Analyzing", @@ -189,6 +195,9 @@ "flags": "Flags", "noFlags": "None", "moreFlags": "+{count} more", + "fileSize": "File size", + "action": "Action", + "view": "View details", "download": "Download", "downloadLabel": "Download {title}" }, @@ -198,6 +207,7 @@ "loadError": "Failed to load documents", "downloadError": "Failed to download document.", "pagination": { + "label": "Document pagination", "summary": "Showing {from}–{to} of {total}" } }, diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 5e3ad73a..389eaf4b 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -161,6 +161,8 @@ }, "documents": { "title": "Todos los documentos", + "subtitle": "Consulta, busca y gestiona tus documentos verificados.", + "upload": "Subir un documento", "filters": { "legend": "Filtros de documentos", "status": "Estado", @@ -173,6 +175,10 @@ "apply": "Aplicar filtros", "reset": "Restablecer" }, + "search": { + "label": "Buscar documentos", + "placeholder": "Buscar documentos por título..." + }, "status": { "pending": "Pendiente", "analyzing": "Analizando", @@ -189,6 +195,9 @@ "flags": "Alertas", "noFlags": "Ninguna", "moreFlags": "+{count} más", + "fileSize": "Tamaño del archivo", + "action": "Acción", + "view": "Ver detalles", "download": "Descargar", "downloadLabel": "Descargar {title}" }, @@ -198,6 +207,7 @@ "loadError": "Error al cargar los documentos", "downloadError": "Error al descargar el documento.", "pagination": { + "label": "Paginación de documentos", "summary": "Mostrando {from}–{to} de {total}" } }, diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index e0c596ce..639476ba 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -161,6 +161,8 @@ }, "documents": { "title": "Tous les documents", + "subtitle": "Consultez, recherchez et gérez vos documents vérifiés.", + "upload": "Téléverser un document", "filters": { "legend": "Filtres de documents", "status": "Statut", @@ -173,6 +175,10 @@ "apply": "Appliquer les filtres", "reset": "Réinitialiser" }, + "search": { + "label": "Rechercher des documents", + "placeholder": "Rechercher des documents par titre..." + }, "status": { "pending": "En attente", "analyzing": "Analyse en cours", @@ -189,6 +195,9 @@ "flags": "Signalements", "noFlags": "Aucun", "moreFlags": "+{count} de plus", + "fileSize": "Taille du fichier", + "action": "Action", + "view": "Voir les détails", "download": "Télécharger", "downloadLabel": "Télécharger {title}" }, @@ -198,6 +207,7 @@ "loadError": "Échec du chargement des documents", "downloadError": "Échec du téléchargement du document.", "pagination": { + "label": "Pagination des documents", "summary": "Affichage de {from} à {to} sur {total}" } }, diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 5d3c8ac5..63c74105 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -58,6 +58,11 @@ export function isProtectedPath(pathname: string): boolean { ); } +export function isAdminPath(pathname: string): boolean { + const routePath = getPathnameWithoutLocale(pathname); + return routePath === "/admin" || routePath.startsWith("/admin/"); +} + function getFirstSegment(pathname: string): string | undefined { return pathname.split("/").filter(Boolean)[0]; } @@ -161,9 +166,14 @@ function createAuthenticationUnavailableResponse(): NextResponse { }); } -async function verifyAccessToken(token: string): Promise { - if (!API_BASE) return "unavailable"; - if (hasControlCharacter(token)) return "unauthenticated"; +interface AccessTokenVerification { + check: AuthCheck; + role?: string; +} + +async function verifyAccessToken(token: string): Promise { + if (!API_BASE) return { check: "unavailable" }; + if (hasControlCharacter(token)) return { check: "unauthenticated" }; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), AUTH_CHECK_TIMEOUT_MS); @@ -180,12 +190,21 @@ async function verifyAccessToken(token: string): Promise { signal: controller.signal, }); - if (response.status === 401) return "unauthenticated"; - if (response.status === 403) return "forbidden"; - if (response.ok) return "authenticated"; - return "unavailable"; + if (response.status === 401) return { check: "unauthenticated" }; + if (response.status === 403) return { check: "forbidden" }; + if (!response.ok) return { check: "unavailable" }; + + const body: unknown = await response.json().catch(() => null); + const role = + body && typeof body === "object" && "role" in body + ? (body as { role?: unknown }).role + : undefined; + return { + check: "authenticated", + role: typeof role === "string" ? role : undefined, + }; } catch { - return "unavailable"; + return { check: "unavailable" }; } finally { clearTimeout(timeout); } @@ -216,18 +235,30 @@ export default async function middleware(request: NextRequest) { } const authCheck = await verifyAccessToken(accessToken); - if (authCheck === "unauthenticated") { + if (authCheck.check === "unauthenticated") { if (refreshToken && !hasControlCharacter(refreshToken)) { return createSessionRefreshRedirect(request, redirectPath); } return createLoginRedirect(request, redirectPath); } - if (authCheck === "forbidden") { + if (authCheck.check === "forbidden") { return createLoginRedirect(request, redirectPath); } - if (authCheck === "unavailable") { + if (authCheck.check === "unavailable") { return createAuthenticationUnavailableResponse(); } + if (isAdminPath(pathname) && authCheck.role !== "admin") { + const url = request.nextUrl.clone(); + const locale = getRequestLocale(request); + const firstSegment = getFirstSegment(pathname); + const localePrefix = + isLocale(firstSegment) || locale !== routing.defaultLocale + ? `/${locale}` + : ""; + url.pathname = localePrefix || "/"; + url.search = ""; + return NextResponse.redirect(url); + } if (unsupportedLocale) { const url = request.nextUrl.clone(); url.pathname = getUnsupportedLocalePath(pathname); diff --git a/frontend/test-utils/admin-route-guard.test.ts b/frontend/test-utils/admin-route-guard.test.ts new file mode 100644 index 00000000..158bfea0 --- /dev/null +++ b/frontend/test-utils/admin-route-guard.test.ts @@ -0,0 +1,19 @@ +import { isAdminPath } from "@/middleware"; + +describe("admin route matching", () => { + it("matches the legacy and localized admin trees", () => { + expect(isAdminPath("/admin")).toBe(true); + expect(isAdminPath("/admin/users")).toBe(true); + expect(isAdminPath("/fr/admin/providers")).toBe(true); + expect(isAdminPath("/es/admin/documents")).toBe(true); + expect(isAdminPath("/%61dmin")).toBe(true); + expect(isAdminPath("/fr/%61dmin")).toBe(true); + }); + + it("does not match similarly named paths", () => { + expect(isAdminPath("/administrator")).toBe(false); + expect(isAdminPath("/fr/administration/users")).toBe(false); + expect(isAdminPath("/fr%2Fadmin")).toBe(false); + expect(isAdminPath("/fr/../admin")).toBe(false); + }); +}); diff --git a/frontend/test-utils/api-config.test.ts b/frontend/test-utils/api-config.test.ts new file mode 100644 index 00000000..3fb3f54b --- /dev/null +++ b/frontend/test-utils/api-config.test.ts @@ -0,0 +1,30 @@ +import { + apiUrl, + normalizeConfiguredBase, + normalizeResourcePath, +} from "@/lib/api-config"; + +describe("api configuration", () => { + it("adds one versioned prefix to an origin", () => { + expect(normalizeConfiguredBase("https://api.example.test")).toBe( + "https://api.example.test/api/v1", + ); + }); + + it("does not duplicate a versioned path already present in the environment", () => { + expect(normalizeConfiguredBase("https://api.example.test/api/v1")).toBe( + "https://api.example.test/api/v1", + ); + expect(normalizeConfiguredBase("https://api.example.test/api")).toBe( + "https://api.example.test/api/v1", + ); + }); + + it("normalizes legacy API resource paths", () => { + expect(normalizeResourcePath("/api/v1/documents")).toBe("/documents"); + expect(normalizeResourcePath("/api/documents")).toBe("/documents"); + expect(apiUrl("/api/v1/documents", { page: 1 })).toContain( + "/api/v1/documents?page=1", + ); + }); +}); diff --git a/frontend/test-utils/auth-session.test.ts b/frontend/test-utils/auth-session.test.ts new file mode 100644 index 00000000..1c9c8ef5 --- /dev/null +++ b/frontend/test-utils/auth-session.test.ts @@ -0,0 +1,18 @@ +import { + DEFAULT_POST_LOGIN_PATH, + resolvePostLoginPath, +} from "@/lib/auth-session"; + +describe("post-login paths", () => { + it("uses the default route when no safe destination is provided", () => { + expect(DEFAULT_POST_LOGIN_PATH).toBe("/"); + expect(resolvePostLoginPath(null)).toBe("/"); + expect(resolvePostLoginPath("https://evil.example")).toBe("/"); + }); + + it("removes repeated locale prefixes before locale-aware navigation", () => { + expect(resolvePostLoginPath("/fr/admin/users")).toBe("/admin/users"); + expect(resolvePostLoginPath("/fr/fr/admin/users")).toBe("/admin/users"); + expect(resolvePostLoginPath("/fr")).toBe("/"); + }); +}); diff --git a/frontend/test-utils/documents-page.test.tsx b/frontend/test-utils/documents-page.test.tsx index b94b84da..09ae90ed 100644 --- a/frontend/test-utils/documents-page.test.tsx +++ b/frontend/test-utils/documents-page.test.tsx @@ -23,8 +23,6 @@ jest.mock("@/i18n/navigation", () => ({ getPathname: () => "/admin/documents", })); -const API_BASE = "http://localhost:3001"; - const originalFetch = globalThis.fetch; let mockFetch: jest.Mock; @@ -77,7 +75,7 @@ function renderPage() { describe("AdminDocumentsPage", () => { it("loads and renders documents from the mocked API", async () => { mockFetch.mockImplementation((url: string) => { - if (url.includes("/api/admin/documents")) { + if (url.includes("/api/v1/admin/documents")) { return Promise.resolve( mockJsonResponse({ data: [ diff --git a/frontend/test-utils/map-state.test.ts b/frontend/test-utils/map-state.test.ts new file mode 100644 index 00000000..f96c81d5 --- /dev/null +++ b/frontend/test-utils/map-state.test.ts @@ -0,0 +1,73 @@ +import { + LAST_VIEWED_PARCEL_KEY, + clearLastViewedParcel, + readLastViewedParcelId, + saveLastViewedParcelId, +} from "@/lib/map-state"; + +let storage: { + getItem: jest.Mock; + setItem: jest.Mock; + removeItem: jest.Mock; + key: jest.Mock; + length: number; +}; + +beforeEach(() => { + storage = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + key: jest.fn(), + length: 0, + }; + Object.defineProperty(window, "sessionStorage", { + configurable: true, + value: storage, + }); +}); + +describe("map session state", () => { + it("uses a user-scoped sessionStorage key", () => { + storage.getItem.mockReturnValue("doc-1"); + + expect(readLastViewedParcelId("user-1")).toBe("doc-1"); + saveLastViewedParcelId("doc-2", "user-1"); + + expect(storage.getItem).toHaveBeenCalledWith( + `${LAST_VIEWED_PARCEL_KEY}:user-1`, + ); + expect(storage.setItem).toHaveBeenCalledWith( + `${LAST_VIEWED_PARCEL_KEY}:user-1`, + "doc-2", + ); + }); + + it("clears a specific user and all users on logout", () => { + clearLastViewedParcel("user-1"); + expect(storage.removeItem).toHaveBeenCalledWith( + `${LAST_VIEWED_PARCEL_KEY}:user-1`, + ); + + storage.removeItem.mockClear(); + storage.key.mockReturnValue(`${LAST_VIEWED_PARCEL_KEY}:user-1`); + Object.defineProperty(storage, "length", { configurable: true, value: 1 }); + clearLastViewedParcel(); + expect(storage.removeItem).toHaveBeenCalledWith(LAST_VIEWED_PARCEL_KEY); + expect(storage.removeItem).toHaveBeenCalledWith( + `${LAST_VIEWED_PARCEL_KEY}:user-1`, + ); + }); + + it("fails closed when session storage is unavailable", () => { + storage.getItem.mockImplementation(() => { + throw new Error("storage unavailable"); + }); + storage.setItem.mockImplementation(() => { + throw new Error("storage unavailable"); + }); + + expect(readLastViewedParcelId("user-1")).toBeNull(); + expect(() => saveLastViewedParcelId("doc-1", "user-1")).not.toThrow(); + }); +}); diff --git a/frontend/test-utils/mocks/handlers.ts b/frontend/test-utils/mocks/handlers.ts index 958f79aa..5dbc89d0 100644 --- a/frontend/test-utils/mocks/handlers.ts +++ b/frontend/test-utils/mocks/handlers.ts @@ -18,6 +18,27 @@ export const handlers = [ }); }), + http.get(`${API_BASE}/api/v1/auth/me`, ({ request }) => { + const auth = request.headers.get("Authorization"); + if (!auth) { + return HttpResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + return HttpResponse.json({ + id: "user-1", + email: "alice@example.com", + fullName: "Alice Smith", + role: "admin", + }); + }), + + http.post(`${API_BASE}/api/v1/auth/refresh`, () => { + return HttpResponse.json({ access_token: "refreshed-jwt-token" }); + }), + + http.post(`${API_BASE}/api/v1/auth/logout`, () => { + return HttpResponse.json({ message: "Logged out successfully" }); + }), + // Auth — verify http.get(`${API_BASE}/api/v1/auth/verify`, ({ request }) => { const auth = request.headers.get("Authorization"); @@ -28,7 +49,7 @@ export const handlers = [ }), // Documents — list - http.get(`${API_BASE}/api/admin/documents`, ({ request }) => { + http.get(`${API_BASE}/api/v1/admin/documents`, ({ request }) => { const url = new URL(request.url); const page = Number(url.searchParams.get("page") ?? "1"); return HttpResponse.json({ @@ -53,8 +74,21 @@ export const handlers = [ }); }), + http.get(`${API_BASE}/api/v1/documents`, () => { + return HttpResponse.json({ + data: [], + total: 0, + page: 1, + limit: 20, + }); + }), + + http.get(`${API_BASE}/api/v1/disputes`, () => { + return HttpResponse.json({ data: [], total: 0 }); + }), + // Documents — export PDF - http.get(`${API_BASE}/api/documents/:id/export/pdf`, () => { + http.get(`${API_BASE}/api/v1/documents/:id/export/pdf`, () => { return HttpResponse.arrayBuffer(new ArrayBuffer(0), { headers: { "Content-Type": "application/pdf" }, }); diff --git a/frontend/test-utils/session.test.ts b/frontend/test-utils/session.test.ts new file mode 100644 index 00000000..6bdc85aa --- /dev/null +++ b/frontend/test-utils/session.test.ts @@ -0,0 +1,50 @@ +import { logoutSession } from "@/lib/session"; + +const originalFetch = globalThis.fetch; +let mockFetch: jest.Mock; +let store: Record; + +beforeEach(() => { + store = {}; + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: jest.fn((key: string) => store[key] ?? null), + setItem: jest.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: jest.fn((key: string) => { + delete store[key]; + }), + }, + }); + mockFetch = jest.fn().mockResolvedValue(new Response(null, { status: 200 })); + globalThis.fetch = mockFetch as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("logoutSession", () => { + it("calls the versioned backend endpoint and clears local credentials", async () => { + store["auth-token"] = "access-token"; + store["auth-refresh-token"] = "refresh-token"; + + await logoutSession(); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:3001/api/v1/auth/logout", + expect.objectContaining({ + method: "POST", + credentials: "include", + headers: { + Authorization: "Bearer access-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ refreshToken: "refresh-token" }), + }), + ); + expect(store["auth-token"]).toBeUndefined(); + }); +}); diff --git a/frontend/test-utils/user-documents-page.test.tsx b/frontend/test-utils/user-documents-page.test.tsx new file mode 100644 index 00000000..5fb750c7 --- /dev/null +++ b/frontend/test-utils/user-documents-page.test.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; +import messages from "@/messages/en.json"; +import DocumentsListPage from "@/app/[locale]/(protected)/documents/page"; + +jest.mock("@/i18n/navigation", () => ({ + Link: ({ children, ...props }: React.AnchorHTMLAttributes) => ( +
{children} + ), +})); + +const originalFetch = globalThis.fetch; +let mockFetch: jest.Mock; + +function response(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + } as Response; +} + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + mockFetch = jest.fn(); + globalThis.fetch = mockFetch as unknown as typeof fetch; + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: jest.fn((key: string) => + key === "auth-token" ? "test-token" : null, + ), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + }, + }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("DocumentsListPage", () => { + it("requests the bounded first page and renders the response contract", async () => { + mockFetch.mockResolvedValue( + response({ + data: [ + { + id: "doc-1", + title: "Land title", + status: "verified", + fileSize: 1024, + createdAt: "2026-07-28T00:00:00.000Z", + }, + ], + total: 6, + page: 1, + limit: 5, + }), + ); + + renderPage(); + + await waitFor(() => expect(screen.getByText("Land title")).toBeInTheDocument()); + expect(String(mockFetch.mock.calls[0][0])).toContain("/api/v1/documents"); + expect(String(mockFetch.mock.calls[0][0])).toContain("page=1"); + expect(String(mockFetch.mock.calls[0][0])).toContain("limit=5"); + expect(screen.getByRole("navigation", { name: "Document pagination" })).toBeInTheDocument(); + }); + + it("requests the next page when the next control is used", async () => { + mockFetch.mockImplementation((url: string) => { + const page = Number(new URL(url).searchParams.get("page") ?? "1"); + return Promise.resolve( + response({ + data: [ + { + id: page === 1 ? "doc-1" : "doc-6", + title: page === 1 ? "First page" : "Last page", + status: "pending", + createdAt: "2026-07-28T00:00:00.000Z", + }, + ], + total: 6, + page, + limit: 5, + }), + ); + }); + + renderPage(); + await waitFor(() => expect(screen.getByText("First page")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + await waitFor(() => expect(screen.getByText("Last page")).toBeInTheDocument()); + expect(String(mockFetch.mock.calls[1][0])).toContain("page=2"); + expect(String(mockFetch.mock.calls[1][0])).toContain("limit=5"); + }); +});