mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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> = {};
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user