diff --git a/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts index cf12fd4d3..8b5c82e9c 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts @@ -4,24 +4,17 @@ import { HttpException, Injectable, NestInterceptor, -} from '@nestjs/common'; -import { Observable, tap } from 'rxjs'; -import type { Request, Response } from 'express'; +} from "@nestjs/common"; +import { Observable, tap } from "rxjs"; +import type { Request, Response } from "express"; -import { AuditService } from './audit.service'; -import { - auditEndpointMatcher, - type MatchedAuditEndpoint, -} from './audit-endpoint-matcher'; -import { - isAuditableActor, - resolveAuditActor, - type AuditActorSource, -} from './audit-actor'; -import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer'; +import { AuditService } from "./audit.service"; +import { auditEndpointMatcher, type MatchedAuditEndpoint } from "./audit-endpoint-matcher"; +import { isAuditableActor, resolveAuditActor, type AuditActorSource } from "./audit-actor"; +import { redactUrlQuery, sanitizeRequestPayload } from "./audit.sanitizer"; /** Methods that can change state. Everything else is never audited. */ -const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); +const AUDITED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); /** `error_message` ceiling — stack traces do not belong in this column. */ const MAX_ERROR_LENGTH = 2_000; @@ -52,7 +45,7 @@ export class AuditInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { // Non-HTTP contexts (the RabbitMQ microservice transport) have no request. - if (context.getType() !== 'http') return next.handle(); + if (context.getType() !== "http") return next.handle(); const httpContext = context.switchToHttp(); const request = httpContext.getRequest(); @@ -72,10 +65,7 @@ export class AuditInterceptor implements NestInterceptor { const startedAt = Date.now(); // The body is captured up front: handlers are free to mutate the DTO they // are given, so reading it after the fact can record post-mutation values. - const requestPayload = sanitizeRequestPayload( - request.body, - request.files ?? request.file, - ); + const requestPayload = sanitizeRequestPayload(request.body, request.files ?? request.file); return next.handle().pipe( tap({ @@ -137,7 +127,6 @@ export class AuditInterceptor implements NestInterceptor { resourceId: matched.resourceId, request: requestPayload, ipAddress: resolveIp(request), - userAgent: request.headers['user-agent'] ?? null, requestId: resolveRequestId(request), durationMs: Date.now() - startedAt, }); @@ -154,10 +143,10 @@ function resolveErrorMessage(error: unknown): string | null { if (error instanceof HttpException) { const response = error.getResponse(); const message = - typeof response === 'string' + typeof response === "string" ? response : ((response as { message?: unknown })?.message ?? error.message); - const text = Array.isArray(message) ? message.join('; ') : String(message); + const text = Array.isArray(message) ? message.join("; ") : String(message); return text.slice(0, MAX_ERROR_LENGTH); } @@ -171,19 +160,19 @@ function resolveErrorMessage(error: unknown): string | null { * entry (the original client) taken. */ function resolveIp(request: Request): string | null { - const forwarded = request.headers['x-forwarded-for']; + const forwarded = request.headers["x-forwarded-for"]; const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded; - const candidate = raw?.split(',')[0]?.trim() || request.ip; + const candidate = raw?.split(",")[0]?.trim() || request.ip; if (!candidate) return null; // Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column // accepts but which reads badly and breaks grouping by address. - return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate; + return candidate.startsWith("::ffff:") ? candidate.slice(7) : candidate; } /** Correlation id from the proxy/tracing layer, when present. */ function resolveRequestId(request: RequestWithUser): string | null { - const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id']; + const header = request.headers["x-request-id"] ?? request.headers["x-correlation-id"]; const value = Array.isArray(header) ? header[0] : header; return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null; } diff --git a/apps/edr-freight-web/backoffice/src/auth/cookies.ts b/apps/edr-freight-web/backoffice/src/auth/cookies.ts index 0292f7f38..dd7edc5a4 100644 --- a/apps/edr-freight-web/backoffice/src/auth/cookies.ts +++ b/apps/edr-freight-web/backoffice/src/auth/cookies.ts @@ -1,3 +1,5 @@ +import { POSITION_COOKIE } from "@/shared/utils/positionCookie"; + const DEFAULT_PATH = "/"; const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7; @@ -32,6 +34,8 @@ export const clearSessionCookies = () => { AUTH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, AUTH_USER_COOKIE, + POSITION_COOKIE, + // Pre-rename name, still cleared so a stale value cannot outlive logout. "current-position-id", "selected-position-id", ].forEach(clearCookie); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index 0144db306..45c2f36f1 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -22,6 +22,7 @@ import { type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton"; +import { PositionSelect } from "@/record-management/components/positionSelection"; import NotificationBellContainer from "@/features/notifications/NotificationBellContainer"; import type { PageMeta } from "./types"; @@ -111,6 +112,10 @@ const FreightDashboardHeader = ({ renders only during a review phase that still has undecided requests, so it never competes for space otherwise. */} + {/* Staff holding two posts switch desks here. Renders nothing for the + single-position majority, so it costs the header no space. */} + + diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/positionSelection.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/positionSelection.tsx index b014425e5..6e904066a 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/positionSelection.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/positionSelection.tsx @@ -18,6 +18,10 @@ import { TooltipTrigger, } from "@/shared/common/ui/tooltip"; import { PositionName } from "../dto/delegation/delegationDto"; +import { + getPositionCookie, + setPositionCookie, +} from "@/shared/utils/positionCookie"; interface BasePosition { id: string; employeePositionId: string; @@ -55,7 +59,7 @@ export const PositionSelect = () => { ? unFilteredUserDetails.employee.flatMap((emp) => emp?.positions ?? []) : []; const selectablePositions = allPositions ?? []; - const currentPositionCookie = Cookies.get("current-position-id"); + const currentPositionCookie = getPositionCookie(); const delegatedPositionCookie = Cookies.get("delegatedPositionId"); const activePositionId = selectedPositionId || currentPositionCookie || delegatedPositionCookie; @@ -85,15 +89,18 @@ export const PositionSelect = () => { // (useAuthUser already self-heals this cookie for the same reason.) if ( currentPosition.employeePositionId && - Cookies.get("current-position-id") !== currentPosition.employeePositionId + getPositionCookie() !== currentPosition.employeePositionId ) { - Cookies.set("current-position-id", currentPosition.employeePositionId); + setPositionCookie(currentPosition.employeePositionId); } applyDelegationCookie(currentPosition); }, [currentPosition, isLoading, selectedPositionId, setSelectedPositionId]); - if (isLoading || selectablePositions.length === 0) return null; + // Below two desks there is nothing to switch between. This now sits in the + // main freight header, so a one-option dropdown would show for every + // single-desk staff member. + if (isLoading || selectablePositions.length < 2) return null; const handleChange = (value: string) => { const selected = selectablePositions.find((pos) => pos.id === value); @@ -102,7 +109,7 @@ export const PositionSelect = () => { // dropdown's own value is position.id, which the API does not match on. setSelectedPositionId(selected?.employeePositionId ?? value); if (selected?.employeePositionId) { - Cookies.set("current-position-id", selected.employeePositionId); + setPositionCookie(selected.employeePositionId); } applyDelegationCookie(selected); diff --git a/apps/edr-freight-web/backoffice/src/record-management/services/api/signatureAndTeeterService.ts b/apps/edr-freight-web/backoffice/src/record-management/services/api/signatureAndTeeterService.ts index bb9d8b274..cefeb94ef 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/services/api/signatureAndTeeterService.ts +++ b/apps/edr-freight-web/backoffice/src/record-management/services/api/signatureAndTeeterService.ts @@ -12,9 +12,11 @@ import { } from "@/record-management/dto/userRecords/teetersAndSignatureDto"; import Cookies from "js-cookie"; +import { getPositionCookie } from "@/shared/utils/positionCookie"; + export const withHeaders = (passPosId: boolean = false) => { const unitId = Cookies.get("unit-id"); - const positionId = Cookies.get("current-position-id"); + const positionId = getPositionCookie(); const projectId = Cookies.get("current-project-id"); const delegatedPositionId = Cookies.get("delegatedPositionId"); diff --git a/apps/edr-freight-web/backoffice/src/record-management/services/api/withHeaders.tsx b/apps/edr-freight-web/backoffice/src/record-management/services/api/withHeaders.tsx index b735ce611..e00325c24 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/services/api/withHeaders.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/services/api/withHeaders.tsx @@ -1,9 +1,11 @@ import Cookies from "js-cookie"; +import { getPositionCookie } from "@/shared/utils/positionCookie"; + export const withHeaders = () => { const tenantKey = Cookies.get("tenant-key"); const unitId = Cookies.get("unit-id"); - const positionId = Cookies.get("current-position-id"); + const positionId = getPositionCookie(); const projectId = Cookies.get("current-project-id"); const delegatedPositionId = Cookies.get("delegatedPositionId"); const headers: Record = {}; diff --git a/apps/edr-freight-web/backoffice/src/shared/hooks/useAuthUser.ts b/apps/edr-freight-web/backoffice/src/shared/hooks/useAuthUser.ts index af41dc2b7..bd589d51b 100644 --- a/apps/edr-freight-web/backoffice/src/shared/hooks/useAuthUser.ts +++ b/apps/edr-freight-web/backoffice/src/shared/hooks/useAuthUser.ts @@ -30,6 +30,10 @@ import { persistRememberMePreference, setAuthCookies, } from "@/shared/utils/authPersistence"; +import { + getPositionCookie, + setPositionCookie, +} from "@/shared/utils/positionCookie"; import { clearComplaintVerification } from "@/complaints/utils/complaintVerificationStorage"; interface LoginPayload { @@ -46,7 +50,7 @@ export const useAuthUser = () => { const { t } = useTranslation(); const { handleError } = useErrorHandler(t); const delegatedPositionId = Cookies.get("delegatedPositionId"); - const currentPositionId = Cookies.get("current-position-id"); + const currentPositionId = getPositionCookie(); const { setUser, @@ -96,7 +100,7 @@ export const useAuthUser = () => { userDetails.employee?.[0]?.positions?.[0]?.employeePositionId; if (firstPositionId) { setSelectedPositionId(firstPositionId); - Cookies.set("current-position-id", firstPositionId, cookieOptions); + setPositionCookie(firstPositionId, cookieOptions); } } @@ -141,7 +145,7 @@ export const useAuthUser = () => { if (fallbackId) { setSelectedPositionId(fallbackId); - Cookies.set("current-position-id", fallbackId); + setPositionCookie(fallbackId); } } else if (selectedPositionId) { // Self-heal stale cookies that were set to position.id instead of @@ -157,10 +161,7 @@ export const useAuthUser = () => { if (matchingPosition?.employeePositionId) { setSelectedPositionId(matchingPosition.employeePositionId); - Cookies.set( - "current-position-id", - matchingPosition.employeePositionId, - ); + setPositionCookie(matchingPosition.employeePositionId); } } } diff --git a/apps/edr-freight-web/backoffice/src/shared/utils/faydaAuthSession.ts b/apps/edr-freight-web/backoffice/src/shared/utils/faydaAuthSession.ts index 8200f6aba..8dce901b3 100644 --- a/apps/edr-freight-web/backoffice/src/shared/utils/faydaAuthSession.ts +++ b/apps/edr-freight-web/backoffice/src/shared/utils/faydaAuthSession.ts @@ -8,6 +8,7 @@ import { getAuthCookieOptions, setAuthCookies, } from "@/shared/utils/authPersistence"; +import { setPositionCookie } from "@/shared/utils/positionCookie"; import type { VerifiedCitizen } from "@/complaints/types/complaint.types"; function unwrapApiData(payload: T | { data?: T }): T { @@ -138,7 +139,7 @@ export async function persistFaydaRegistrationAuth( const firstPositionId = userDetails.employee?.[0]?.positions?.[0]?.employeePositionId; if (firstPositionId) { - Cookies.set("current-position-id", firstPositionId, cookieOptions); + setPositionCookie(firstPositionId, cookieOptions); } return userDetails; diff --git a/apps/edr-freight-web/backoffice/src/shared/utils/positionCookie.ts b/apps/edr-freight-web/backoffice/src/shared/utils/positionCookie.ts new file mode 100644 index 000000000..60c97c6ae --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/shared/utils/positionCookie.ts @@ -0,0 +1,39 @@ +import Cookies from "js-cookie"; + +type CookieOptions = NonNullable[2]>; + +/** + * Freight's own active-position cookie. + * + * Smart Office is a separate app on the same IAM and it also writes a cookie + * named `current-position-id` — but it stores `position.id` where freight + * stores `employeePositionId`. The two are not interchangeable, so on a shared + * domain each app's login silently overwrote the other's desk selection and the + * loser fell back to `positions[0]`. Freight keeps its own cookie name so both + * can hold a selection at once. + */ +export const POSITION_COOKIE = "freight-current-position-id"; + +/** + * The pre-rename, shared-with-Smart-Office name. Still read so a session that + * is live across the deploy keeps its desk, and cleared on every write so the + * colliding cookie does not linger. + */ +const LEGACY_POSITION_COOKIE = "current-position-id"; + +/** The active `employeePositionId`, or undefined when no desk is selected. */ +export const getPositionCookie = (): string | undefined => + Cookies.get(POSITION_COOKIE) ?? Cookies.get(LEGACY_POSITION_COOKIE); + +export const setPositionCookie = ( + value: string, + options?: CookieOptions, +): void => { + Cookies.set(POSITION_COOKIE, value, options); + Cookies.remove(LEGACY_POSITION_COOKIE); +}; + +export const clearPositionCookie = (): void => { + Cookies.remove(POSITION_COOKIE); + Cookies.remove(LEGACY_POSITION_COOKIE); +};