fix(backoffice): stop sharing the position cookie with Smart Office

Both apps wrote a cookie named `current-position-id` but stored
different ids in it — freight the `employeePositionId`, Smart Office the
`position.id`. On a shared domain each login overwrote the other's desk
selection, and the loser silently fell back to the first position.

Freight now uses `freight-current-position-id` through a small helper
that reads the old name once, so a session live across the deploy keeps
its desk, and clears it on every write.

Also mounts the position switcher in the freight dashboard header. It
only existed under /performance-management, so on every other page a
two-desk user had no way to switch and was stuck on whatever
`useAuthUser` defaulted to. It now hides below two positions rather than
showing a one-option dropdown to the single-desk majority.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathnael
2026-09-01 07:04:10 +00:00
parent db9d6e49c7
commit 7fd5616188
9 changed files with 92 additions and 42 deletions

View File

@@ -4,24 +4,17 @@ import {
HttpException, HttpException,
Injectable, Injectable,
NestInterceptor, NestInterceptor,
} from '@nestjs/common'; } from "@nestjs/common";
import { Observable, tap } from 'rxjs'; import { Observable, tap } from "rxjs";
import type { Request, Response } from 'express'; import type { Request, Response } from "express";
import { AuditService } from './audit.service'; import { AuditService } from "./audit.service";
import { import { auditEndpointMatcher, type MatchedAuditEndpoint } from "./audit-endpoint-matcher";
auditEndpointMatcher, import { isAuditableActor, resolveAuditActor, type AuditActorSource } from "./audit-actor";
type MatchedAuditEndpoint, import { redactUrlQuery, sanitizeRequestPayload } from "./audit.sanitizer";
} 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. */ /** 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. */ /** `error_message` ceiling — stack traces do not belong in this column. */
const MAX_ERROR_LENGTH = 2_000; const MAX_ERROR_LENGTH = 2_000;
@@ -52,7 +45,7 @@ export class AuditInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
// Non-HTTP contexts (the RabbitMQ microservice transport) have no request. // 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 httpContext = context.switchToHttp();
const request = httpContext.getRequest<RequestWithUser>(); const request = httpContext.getRequest<RequestWithUser>();
@@ -72,10 +65,7 @@ export class AuditInterceptor implements NestInterceptor {
const startedAt = Date.now(); const startedAt = Date.now();
// The body is captured up front: handlers are free to mutate the DTO they // 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. // are given, so reading it after the fact can record post-mutation values.
const requestPayload = sanitizeRequestPayload( const requestPayload = sanitizeRequestPayload(request.body, request.files ?? request.file);
request.body,
request.files ?? request.file,
);
return next.handle().pipe( return next.handle().pipe(
tap({ tap({
@@ -137,7 +127,6 @@ export class AuditInterceptor implements NestInterceptor {
resourceId: matched.resourceId, resourceId: matched.resourceId,
request: requestPayload, request: requestPayload,
ipAddress: resolveIp(request), ipAddress: resolveIp(request),
userAgent: request.headers['user-agent'] ?? null,
requestId: resolveRequestId(request), requestId: resolveRequestId(request),
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
}); });
@@ -154,10 +143,10 @@ function resolveErrorMessage(error: unknown): string | null {
if (error instanceof HttpException) { if (error instanceof HttpException) {
const response = error.getResponse(); const response = error.getResponse();
const message = const message =
typeof response === 'string' typeof response === "string"
? response ? response
: ((response as { message?: unknown })?.message ?? error.message); : ((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); return text.slice(0, MAX_ERROR_LENGTH);
} }
@@ -171,19 +160,19 @@ function resolveErrorMessage(error: unknown): string | null {
* entry (the original client) taken. * entry (the original client) taken.
*/ */
function resolveIp(request: Request): string | null { 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 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; if (!candidate) return null;
// Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column // Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column
// accepts but which reads badly and breaks grouping by address. // 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. */ /** Correlation id from the proxy/tracing layer, when present. */
function resolveRequestId(request: RequestWithUser): string | null { 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; const value = Array.isArray(header) ? header[0] : header;
return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null; return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null;
} }

View File

@@ -1,3 +1,5 @@
import { POSITION_COOKIE } from "@/shared/utils/positionCookie";
const DEFAULT_PATH = "/"; const DEFAULT_PATH = "/";
const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7; const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7;
@@ -32,6 +34,8 @@ export const clearSessionCookies = () => {
AUTH_TOKEN_COOKIE, AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE,
AUTH_USER_COOKIE, AUTH_USER_COOKIE,
POSITION_COOKIE,
// Pre-rename name, still cleared so a stale value cannot outlive logout.
"current-position-id", "current-position-id",
"selected-position-id", "selected-position-id",
].forEach(clearCookie); ].forEach(clearCookie);

View File

@@ -22,6 +22,7 @@ import { type ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton"; import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton";
import { PositionSelect } from "@/record-management/components/positionSelection";
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer"; import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
import type { PageMeta } from "./types"; import type { PageMeta } from "./types";
@@ -111,6 +112,10 @@ const FreightDashboardHeader = ({
renders only during a review phase that still has undecided renders only during a review phase that still has undecided
requests, so it never competes for space otherwise. */} requests, so it never competes for space otherwise. */}
<Group gap={10} wrap="nowrap" align="center"> <Group gap={10} wrap="nowrap" align="center">
{/* Staff holding two posts switch desks here. Renders nothing for the
single-position majority, so it costs the header no space. */}
<PositionSelect />
<DocReviewAlertButton /> <DocReviewAlertButton />
<Tooltip label="Language" withArrow openDelay={300}> <Tooltip label="Language" withArrow openDelay={300}>

View File

@@ -18,6 +18,10 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/shared/common/ui/tooltip"; } from "@/shared/common/ui/tooltip";
import { PositionName } from "../dto/delegation/delegationDto"; import { PositionName } from "../dto/delegation/delegationDto";
import {
getPositionCookie,
setPositionCookie,
} from "@/shared/utils/positionCookie";
interface BasePosition { interface BasePosition {
id: string; id: string;
employeePositionId: string; employeePositionId: string;
@@ -55,7 +59,7 @@ export const PositionSelect = () => {
? unFilteredUserDetails.employee.flatMap((emp) => emp?.positions ?? []) ? unFilteredUserDetails.employee.flatMap((emp) => emp?.positions ?? [])
: []; : [];
const selectablePositions = allPositions ?? []; const selectablePositions = allPositions ?? [];
const currentPositionCookie = Cookies.get("current-position-id"); const currentPositionCookie = getPositionCookie();
const delegatedPositionCookie = Cookies.get("delegatedPositionId"); const delegatedPositionCookie = Cookies.get("delegatedPositionId");
const activePositionId = const activePositionId =
selectedPositionId || currentPositionCookie || delegatedPositionCookie; selectedPositionId || currentPositionCookie || delegatedPositionCookie;
@@ -85,15 +89,18 @@ export const PositionSelect = () => {
// (useAuthUser already self-heals this cookie for the same reason.) // (useAuthUser already self-heals this cookie for the same reason.)
if ( if (
currentPosition.employeePositionId && currentPosition.employeePositionId &&
Cookies.get("current-position-id") !== currentPosition.employeePositionId getPositionCookie() !== currentPosition.employeePositionId
) { ) {
Cookies.set("current-position-id", currentPosition.employeePositionId); setPositionCookie(currentPosition.employeePositionId);
} }
applyDelegationCookie(currentPosition); applyDelegationCookie(currentPosition);
}, [currentPosition, isLoading, selectedPositionId, setSelectedPositionId]); }, [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 handleChange = (value: string) => {
const selected = selectablePositions.find((pos) => pos.id === value); 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. // dropdown's own value is position.id, which the API does not match on.
setSelectedPositionId(selected?.employeePositionId ?? value); setSelectedPositionId(selected?.employeePositionId ?? value);
if (selected?.employeePositionId) { if (selected?.employeePositionId) {
Cookies.set("current-position-id", selected.employeePositionId); setPositionCookie(selected.employeePositionId);
} }
applyDelegationCookie(selected); applyDelegationCookie(selected);

View File

@@ -12,9 +12,11 @@ import {
} from "@/record-management/dto/userRecords/teetersAndSignatureDto"; } from "@/record-management/dto/userRecords/teetersAndSignatureDto";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { getPositionCookie } from "@/shared/utils/positionCookie";
export const withHeaders = (passPosId: boolean = false) => { export const withHeaders = (passPosId: boolean = false) => {
const unitId = Cookies.get("unit-id"); const unitId = Cookies.get("unit-id");
const positionId = Cookies.get("current-position-id"); const positionId = getPositionCookie();
const projectId = Cookies.get("current-project-id"); const projectId = Cookies.get("current-project-id");
const delegatedPositionId = Cookies.get("delegatedPositionId"); const delegatedPositionId = Cookies.get("delegatedPositionId");

View File

@@ -1,9 +1,11 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { getPositionCookie } from "@/shared/utils/positionCookie";
export const withHeaders = () => { export const withHeaders = () => {
const tenantKey = Cookies.get("tenant-key"); const tenantKey = Cookies.get("tenant-key");
const unitId = Cookies.get("unit-id"); const unitId = Cookies.get("unit-id");
const positionId = Cookies.get("current-position-id"); const positionId = getPositionCookie();
const projectId = Cookies.get("current-project-id"); const projectId = Cookies.get("current-project-id");
const delegatedPositionId = Cookies.get("delegatedPositionId"); const delegatedPositionId = Cookies.get("delegatedPositionId");
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};

View File

@@ -30,6 +30,10 @@ import {
persistRememberMePreference, persistRememberMePreference,
setAuthCookies, setAuthCookies,
} from "@/shared/utils/authPersistence"; } from "@/shared/utils/authPersistence";
import {
getPositionCookie,
setPositionCookie,
} from "@/shared/utils/positionCookie";
import { clearComplaintVerification } from "@/complaints/utils/complaintVerificationStorage"; import { clearComplaintVerification } from "@/complaints/utils/complaintVerificationStorage";
interface LoginPayload { interface LoginPayload {
@@ -46,7 +50,7 @@ export const useAuthUser = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { handleError } = useErrorHandler(t); const { handleError } = useErrorHandler(t);
const delegatedPositionId = Cookies.get("delegatedPositionId"); const delegatedPositionId = Cookies.get("delegatedPositionId");
const currentPositionId = Cookies.get("current-position-id"); const currentPositionId = getPositionCookie();
const { const {
setUser, setUser,
@@ -96,7 +100,7 @@ export const useAuthUser = () => {
userDetails.employee?.[0]?.positions?.[0]?.employeePositionId; userDetails.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) { if (firstPositionId) {
setSelectedPositionId(firstPositionId); setSelectedPositionId(firstPositionId);
Cookies.set("current-position-id", firstPositionId, cookieOptions); setPositionCookie(firstPositionId, cookieOptions);
} }
} }
@@ -141,7 +145,7 @@ export const useAuthUser = () => {
if (fallbackId) { if (fallbackId) {
setSelectedPositionId(fallbackId); setSelectedPositionId(fallbackId);
Cookies.set("current-position-id", fallbackId); setPositionCookie(fallbackId);
} }
} else if (selectedPositionId) { } else if (selectedPositionId) {
// Self-heal stale cookies that were set to position.id instead of // Self-heal stale cookies that were set to position.id instead of
@@ -157,10 +161,7 @@ export const useAuthUser = () => {
if (matchingPosition?.employeePositionId) { if (matchingPosition?.employeePositionId) {
setSelectedPositionId(matchingPosition.employeePositionId); setSelectedPositionId(matchingPosition.employeePositionId);
Cookies.set( setPositionCookie(matchingPosition.employeePositionId);
"current-position-id",
matchingPosition.employeePositionId,
);
} }
} }
} }

View File

@@ -8,6 +8,7 @@ import {
getAuthCookieOptions, getAuthCookieOptions,
setAuthCookies, setAuthCookies,
} from "@/shared/utils/authPersistence"; } from "@/shared/utils/authPersistence";
import { setPositionCookie } from "@/shared/utils/positionCookie";
import type { VerifiedCitizen } from "@/complaints/types/complaint.types"; import type { VerifiedCitizen } from "@/complaints/types/complaint.types";
function unwrapApiData<T>(payload: T | { data?: T }): T { function unwrapApiData<T>(payload: T | { data?: T }): T {
@@ -138,7 +139,7 @@ export async function persistFaydaRegistrationAuth(
const firstPositionId = const firstPositionId =
userDetails.employee?.[0]?.positions?.[0]?.employeePositionId; userDetails.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) { if (firstPositionId) {
Cookies.set("current-position-id", firstPositionId, cookieOptions); setPositionCookie(firstPositionId, cookieOptions);
} }
return userDetails; return userDetails;

View File

@@ -0,0 +1,39 @@
import Cookies from "js-cookie";
type CookieOptions = NonNullable<Parameters<typeof Cookies.set>[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);
};