mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 07:23:40 +00:00
Merge pull request #1471 from Tria-plc/freight/fix/quick-fixes
fix: matrix
This commit is contained in:
148
apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts
Normal file
148
apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
collectAllPositions,
|
||||
resolveActiveEmployee,
|
||||
type SnapshotEmployee,
|
||||
} from './freight-jwt.guard';
|
||||
|
||||
// Shapes and ids taken from the real dev session for `test_dj_gl_director`
|
||||
// (iam.sessions 800ad793-…), an employee holding two posts on one row.
|
||||
const CHIEF = {
|
||||
id: '990189f1-e872-4b8c-9f6a-36259a0df480',
|
||||
employeePositionId: 'd0d527f6-f344-49aa-ab8b-25a448a770b6',
|
||||
name: { en: 'Djibouti GL Chief' },
|
||||
isDelegate: false,
|
||||
};
|
||||
const DIRECTOR = {
|
||||
id: '258a8d82-28c4-401f-bf88-78f58bb6bd0e',
|
||||
employeePositionId: 'b97aa265-5de8-4ffe-95bf-01f94d38a2df',
|
||||
name: { en: 'Djibouti GL Director' },
|
||||
isDelegate: false,
|
||||
};
|
||||
|
||||
const EMPLOYEE_ID = '70545ee5-c7d7-4196-af7e-a7eb7e76b21b';
|
||||
const oneRow: SnapshotEmployee[] = [
|
||||
{ id: EMPLOYEE_ID, positions: [CHIEF, DIRECTOR] },
|
||||
];
|
||||
|
||||
describe('resolveActiveEmployee', () => {
|
||||
it('leaves the parent guard alone when no position header is sent', () => {
|
||||
const { owner, active } = resolveActiveEmployee(
|
||||
oneRow,
|
||||
undefined,
|
||||
EMPLOYEE_ID,
|
||||
);
|
||||
|
||||
expect(owner).toBe(oneRow[0]);
|
||||
expect(active).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves freight's header value (employeePositionId)", () => {
|
||||
const { active } = resolveActiveEmployee(
|
||||
oneRow,
|
||||
DIRECTOR.employeePositionId,
|
||||
EMPLOYEE_ID,
|
||||
);
|
||||
|
||||
expect(active).toBe(DIRECTOR);
|
||||
});
|
||||
|
||||
// The regression this guard exists for: the stock IAM guard matches the
|
||||
// header against employeePositionId only, so Smart Office's position.id
|
||||
// matched nothing and every request silently ran as positions[0].
|
||||
it("resolves Smart Office's header value (position.id)", () => {
|
||||
const { active } = resolveActiveEmployee(oneRow, DIRECTOR.id, EMPLOYEE_ID);
|
||||
|
||||
expect(active).toBe(DIRECTOR);
|
||||
expect(active).not.toBe(CHIEF);
|
||||
});
|
||||
|
||||
it('falls back to the parent row when the header names nothing', () => {
|
||||
const { owner, active } = resolveActiveEmployee(
|
||||
oneRow,
|
||||
'not-a-position-id',
|
||||
EMPLOYEE_ID,
|
||||
);
|
||||
|
||||
expect(owner).toBe(oneRow[0]);
|
||||
expect(active).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('when the two posts sit on different employee rows', () => {
|
||||
const smartOfficeRow: SnapshotEmployee = {
|
||||
id: 'emp-smart-office',
|
||||
positions: [CHIEF],
|
||||
};
|
||||
const freightRow: SnapshotEmployee = {
|
||||
id: 'emp-freight',
|
||||
positions: [DIRECTOR],
|
||||
};
|
||||
const twoRows = [smartOfficeRow, freightRow];
|
||||
|
||||
it('selects the row that owns the requested position', () => {
|
||||
const { owner, active } = resolveActiveEmployee(
|
||||
twoRows,
|
||||
DIRECTOR.employeePositionId,
|
||||
// The parent guard matches the header against position.id only, so it
|
||||
// matched neither row and fell through to the first.
|
||||
smartOfficeRow.id,
|
||||
);
|
||||
|
||||
expect(owner).toBe(freightRow);
|
||||
expect(active).toBe(DIRECTOR);
|
||||
});
|
||||
|
||||
it('keeps the parent row when no header is sent', () => {
|
||||
const { owner } = resolveActiveEmployee(twoRows, undefined, freightRow.id);
|
||||
|
||||
expect(owner).toBe(freightRow);
|
||||
});
|
||||
|
||||
it('falls back to the first row when the parent row is unknown', () => {
|
||||
const { owner } = resolveActiveEmployee(twoRows, undefined, undefined);
|
||||
|
||||
expect(owner).toBe(smartOfficeRow);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectAllPositions', () => {
|
||||
it('unions posts held across separate employee rows', () => {
|
||||
// The real shape: IAM keeps one employee row per organization, and "EDR"
|
||||
// and "EDR Freight" are separate orgs, so a user holding a Smart Office
|
||||
// post and a freight post owns one row each.
|
||||
const smartOfficeRow: SnapshotEmployee = {
|
||||
id: 'emp-edr',
|
||||
organizationId: 'org-edr',
|
||||
positions: [CHIEF],
|
||||
};
|
||||
const freightRow: SnapshotEmployee = {
|
||||
id: 'emp-edr-freight',
|
||||
organizationId: 'org-edr-freight',
|
||||
positions: [DIRECTOR],
|
||||
};
|
||||
|
||||
expect(collectAllPositions([smartOfficeRow, freightRow])).toEqual([
|
||||
CHIEF,
|
||||
DIRECTOR,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps every post when they share one row', () => {
|
||||
expect(collectAllPositions(oneRow)).toEqual([CHIEF, DIRECTOR]);
|
||||
});
|
||||
|
||||
it('de-duplicates a post repeated across rows', () => {
|
||||
const rows: SnapshotEmployee[] = [
|
||||
{ id: 'a', positions: [CHIEF] },
|
||||
{ id: 'b', positions: [CHIEF, DIRECTOR] },
|
||||
];
|
||||
|
||||
expect(collectAllPositions(rows)).toEqual([CHIEF, DIRECTOR]);
|
||||
});
|
||||
|
||||
it('tolerates rows carrying no positions', () => {
|
||||
const rows: SnapshotEmployee[] = [{ id: 'a' }, { id: 'b', positions: [] }];
|
||||
|
||||
expect(collectAllPositions(rows)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,29 +3,124 @@ import { Reflector } from '@nestjs/core';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { CURRENT_POSITION_ID } from '@tria-plc/api-common/utils/constants/tenant.constant';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
/** One position as the login snapshot stores it (`iam.sessions.userInfo`). */
|
||||
type SnapshotPosition = { id?: string; [key: string]: unknown };
|
||||
export type SnapshotPosition = {
|
||||
id?: string;
|
||||
employeePositionId?: string;
|
||||
isDelegate?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type SessionUserInfo = {
|
||||
employee?: { id?: string; positions?: SnapshotPosition[] }[];
|
||||
/** One employee row as the snapshot stores it. A user may hold several. */
|
||||
export type SnapshotEmployee = {
|
||||
id?: string;
|
||||
positions?: SnapshotPosition[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type SessionUserInfo = { employee?: SnapshotEmployee[] };
|
||||
|
||||
/**
|
||||
* `x-current-position-id` is sent with two different meanings by two different
|
||||
* frontends, and the IAM guard reads it both ways in the same function: it
|
||||
* picks the EMPLOYEE row by `position.id` but the POSITION by
|
||||
* `employeePositionId`. Freight sends `employeePositionId`, Smart Office sends
|
||||
* `position.id` — so whichever value arrives, one of the two lookups silently
|
||||
* matches nothing and falls back to the first entry.
|
||||
*
|
||||
* Matching both fields is what makes the header mean one thing again.
|
||||
*/
|
||||
const identifies = (position: SnapshotPosition, id: string): boolean =>
|
||||
position?.id === id || position?.employeePositionId === id;
|
||||
|
||||
/**
|
||||
* Every post the user holds, across every employee row, first occurrence kept.
|
||||
*
|
||||
* IAM keeps one employee row per ORGANIZATION, and "EDR" and "EDR Freight" are
|
||||
* separate organizations — so a user given a freight post and a Smart Office
|
||||
* post owns two rows, one post on each. Only one row can be the active one, and
|
||||
* a permission check that reads only that row cannot see the other post at all.
|
||||
*/
|
||||
export const collectAllPositions = (
|
||||
employees: SnapshotEmployee[],
|
||||
): SnapshotPosition[] => {
|
||||
const seen = new Set<string>();
|
||||
const all: SnapshotPosition[] = [];
|
||||
|
||||
for (const employee of employees) {
|
||||
for (const position of employee.positions ?? []) {
|
||||
const key = position.employeePositionId ?? position.id;
|
||||
if (key) {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
}
|
||||
all.push(position);
|
||||
}
|
||||
}
|
||||
|
||||
return all;
|
||||
};
|
||||
|
||||
/**
|
||||
* Like the IAM JwtGuard, but keeps the caller's SECONDARY positions.
|
||||
* Which employee row the caller is acting as, and which of its positions the
|
||||
* request selected. Pure so it can be tested without a session or a token.
|
||||
*
|
||||
* IAM models an employee as holding many positions, and the login snapshot in
|
||||
* `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` then
|
||||
* collapses that to a single `employee.position` — whichever the request
|
||||
* headers select, else `positions[0]` — and drops the rest. Non-delegate
|
||||
* secondary positions vanish entirely, so staff holding two posts resolve to
|
||||
* only one post's permissions and every check on the other one rejects them.
|
||||
* `owner` is the row holding the requested position; failing that the row the
|
||||
* parent guard already picked; failing that the first. `active` is undefined
|
||||
* when no header was sent or it names nothing — the caller then leaves the
|
||||
* parent's choice of `employee.position` alone.
|
||||
*/
|
||||
export const resolveActiveEmployee = (
|
||||
employees: SnapshotEmployee[],
|
||||
requestedId: string | undefined,
|
||||
parentEmployeeId: string | undefined,
|
||||
): { owner: SnapshotEmployee | undefined; active: SnapshotPosition | undefined } => {
|
||||
const owner =
|
||||
(requestedId &&
|
||||
employees.find((candidate) =>
|
||||
(candidate.positions ?? []).some((position) =>
|
||||
identifies(position, requestedId),
|
||||
),
|
||||
)) ||
|
||||
employees.find(
|
||||
(candidate) => candidate.id && candidate.id === parentEmployeeId,
|
||||
) ||
|
||||
employees[0];
|
||||
|
||||
const active = requestedId
|
||||
? (owner?.positions ?? []).find((position) =>
|
||||
identifies(position, requestedId),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return { owner, active };
|
||||
};
|
||||
|
||||
/**
|
||||
* Like the IAM JwtGuard, but resolves the caller's position honestly.
|
||||
*
|
||||
* This re-attaches the full list as `employee.positions`. `employee.position`
|
||||
* is left exactly as the parent set it, so everything reading the single
|
||||
* position today (audit log, delegation deadline) is unaffected; only the
|
||||
* permission utils, which prefer the array, see the difference.
|
||||
* IAM models an employee as holding many positions — and a user as possibly
|
||||
* holding several employee rows — and the login snapshot in
|
||||
* `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` collapses
|
||||
* that to a single `employee.position` and drops the rest, so staff holding two
|
||||
* posts resolve to one post's permissions and every check on the other one
|
||||
* rejects them.
|
||||
*
|
||||
* This guard re-reads the snapshot and fixes three things the parent gets wrong:
|
||||
*
|
||||
* 1. re-attaches the full position list as `employee.positions`, which is what
|
||||
* the permission utils union over;
|
||||
* 2. selects the employee row that actually owns the requested position, so a
|
||||
* post held on a second employee row is reachable at all;
|
||||
* 3. sets `employee.position` to the requested position when the parent's
|
||||
* one-sided id match missed it, keeping `auditUser` in step.
|
||||
*
|
||||
* Every correction is skipped unless the snapshot positively resolves it, so an
|
||||
* unreadable session degrades to the parent's single-position behaviour rather
|
||||
* than to no position at all.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
|
||||
@@ -35,7 +130,7 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
|
||||
private static readonly CACHE_MAX_ENTRIES = 5_000;
|
||||
private readonly cache = new Map<
|
||||
string,
|
||||
{ positions: SnapshotPosition[]; expiresAt: number }
|
||||
{ employees: SnapshotEmployee[]; expiresAt: number }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
@@ -48,44 +143,78 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
if (!(await super.canActivate(context))) return false;
|
||||
|
||||
const user = context.switchToHttp().getRequest().user as
|
||||
| TCurrentUser
|
||||
| undefined;
|
||||
const employee = user?.employee;
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user as TCurrentUser | undefined;
|
||||
const employee = user?.employee as SnapshotEmployee | undefined;
|
||||
if (!employee || !user?.sessionId) return true;
|
||||
|
||||
const positions = await this.positionsForSession(
|
||||
user.sessionId,
|
||||
const employees = await this.employeesForSession(user.sessionId);
|
||||
if (!employees.length) return true;
|
||||
|
||||
const requestedId = request.headers?.[CURRENT_POSITION_ID] as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
const { owner, active } = resolveActiveEmployee(
|
||||
employees,
|
||||
requestedId,
|
||||
employee.id,
|
||||
);
|
||||
// Never blank out what the parent resolved: an unreadable session or a
|
||||
// snapshot without positions must degrade to the single-position
|
||||
// behaviour, not to no positions at all.
|
||||
if (positions.length) {
|
||||
(employee as { positions?: SnapshotPosition[] }).positions = positions;
|
||||
|
||||
const ownerPositions = owner?.positions ?? [];
|
||||
// Never blank out what the parent resolved: a snapshot without positions
|
||||
// must degrade to the single-position behaviour, not to no positions.
|
||||
if (!ownerPositions.length) return true;
|
||||
|
||||
// Carries the owning row's id / unitId / organizationId too, which unit
|
||||
// scoping downstream reads — a swapped row must be swapped whole.
|
||||
Object.assign(employee, owner);
|
||||
|
||||
// `collectPermissionKeys` / `collectPositionTypeKeys` union over this, and
|
||||
// a user's posts can span several employee rows (one per organization), so
|
||||
// it carries every row's — otherwise a freight post is invisible whenever
|
||||
// another organization's row wins the active slot.
|
||||
employee.positions = collectAllPositions(employees);
|
||||
|
||||
// Delegation stays scoped to the active desk: yard scope widens on
|
||||
// `delegatedPositions`, and someone standing in on another organization's
|
||||
// row is not this desk's stand-in.
|
||||
employee.delegatedPositions = ownerPositions.filter(
|
||||
(position) => position.isDelegate,
|
||||
);
|
||||
|
||||
// The full set, for `/auth/me` — the position picker has to be able to
|
||||
// offer a desk on a row that is not the active one.
|
||||
(user as { employeeRows?: SnapshotEmployee[] }).employeeRows = employees;
|
||||
|
||||
if (active) {
|
||||
employee.position = active;
|
||||
// The parent already built `auditUser` from the position it guessed.
|
||||
if (request.auditUser) {
|
||||
request.auditUser.employeeId = employee.id;
|
||||
request.auditUser.positionId = active.id;
|
||||
request.auditUser.employeePositionId = active.employeePositionId;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Every position the login snapshot holds for this employee. */
|
||||
private async positionsForSession(
|
||||
/** Every employee row the login snapshot holds for this session. */
|
||||
private async employeesForSession(
|
||||
sessionId: string,
|
||||
employeeId: string | undefined,
|
||||
): Promise<SnapshotPosition[]> {
|
||||
): Promise<SnapshotEmployee[]> {
|
||||
const now = Date.now();
|
||||
const hit = this.cache.get(sessionId);
|
||||
if (hit && hit.expiresAt > now) return hit.positions;
|
||||
if (hit && hit.expiresAt > now) return hit.employees;
|
||||
|
||||
let positions: SnapshotPosition[] = [];
|
||||
let employees: SnapshotEmployee[] = [];
|
||||
try {
|
||||
const rows: { userInfo: SessionUserInfo | null }[] = await this.ds.query(
|
||||
`SELECT "userInfo" FROM iam.sessions WHERE id = $1`,
|
||||
[sessionId],
|
||||
);
|
||||
const employees = rows[0]?.userInfo?.employee ?? [];
|
||||
const match =
|
||||
employees.find((e) => e?.id && e.id === employeeId) ?? employees[0];
|
||||
positions = match?.positions ?? [];
|
||||
employees = rows[0]?.userInfo?.employee ?? [];
|
||||
} catch {
|
||||
return []; // iam unreachable — caller keeps the parent's single position
|
||||
}
|
||||
@@ -93,9 +222,9 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
|
||||
if (this.cache.size >= FreightJwtGuard.CACHE_MAX_ENTRIES)
|
||||
this.cache.clear();
|
||||
this.cache.set(sessionId, {
|
||||
positions,
|
||||
employees,
|
||||
expiresAt: now + FreightJwtGuard.CACHE_TTL_MS,
|
||||
});
|
||||
return positions;
|
||||
return employees;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<unknown> {
|
||||
// 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<RequestWithUser>();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import type { SnapshotEmployee } from '../../common/freight-jwt.guard';
|
||||
import {
|
||||
collectPermissionKeys,
|
||||
isSuperAdmin,
|
||||
@@ -82,8 +83,26 @@ export class FreightMeService {
|
||||
? [employeeRecord.position]
|
||||
: [];
|
||||
|
||||
const enrichedPositions = await Promise.all(
|
||||
rawPositions.map(async (position) => {
|
||||
// IAM keeps one employee row per organization, so a user holding a freight
|
||||
// post and a Smart Office post owns two rows. The backoffice reads
|
||||
// `employee` as an array and the position picker lists what it finds there
|
||||
// — returning only the active row hides the other desk and makes it
|
||||
// unselectable. `FreightJwtGuard` leaves the full set here.
|
||||
const employeeRows = (user as { employeeRows?: SnapshotEmployee[] })
|
||||
.employeeRows;
|
||||
|
||||
// Active row first: the backoffice reads `employee[0]` for
|
||||
// unitId/organizationId, so the desk the caller is acting as must lead.
|
||||
const rows: SnapshotEmployee[] = employeeRows?.length
|
||||
? [
|
||||
...employeeRows.filter((row) => row.id === employeeRecord?.id),
|
||||
...employeeRows.filter((row) => row.id !== employeeRecord?.id),
|
||||
]
|
||||
: employeeRecord
|
||||
? [{ ...employeeRecord, positions: rawPositions } as SnapshotEmployee]
|
||||
: [];
|
||||
|
||||
const enrichPosition = async (position: TokenPosition) => {
|
||||
const [positionType, positionTypePermissionKeys] = await Promise.all([
|
||||
this.lookupPositionType(position.id),
|
||||
this.lookupPositionTypePermissions(position.id),
|
||||
@@ -114,20 +133,24 @@ export class FreightMeService {
|
||||
positionType,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const enrichedRows = await Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
row,
|
||||
positions: await Promise.all(
|
||||
((row.positions ?? []) as TokenPosition[]).map(enrichPosition),
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
const employee = employeeRecord
|
||||
? [
|
||||
{
|
||||
id: employeeRecord.id,
|
||||
organizationId: employeeRecord.organizationId,
|
||||
unitId: employeeRecord.unitId,
|
||||
name: employeeRecord.name,
|
||||
positions: enrichedPositions.map((p) => p.position),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const employee = enrichedRows.map(({ row, positions }) => ({
|
||||
id: row.id as string,
|
||||
organizationId: row.organizationId as string,
|
||||
unitId: row.unitId as string,
|
||||
name: row.name,
|
||||
positions: positions.map((p) => p.position),
|
||||
}));
|
||||
|
||||
// `collectPermissionKeys` reads the raw token (position-level only), so
|
||||
// union the type-level grants in — the backoffice prefers this flat list
|
||||
@@ -135,7 +158,9 @@ export class FreightMeService {
|
||||
const permissionKeys = [
|
||||
...new Set([
|
||||
...collectPermissionKeys(user),
|
||||
...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys),
|
||||
...enrichedRows.flatMap(({ positions }) =>
|
||||
positions.flatMap((p) => p.positionTypePermissionKeys),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { NotificationType, type NotifyInput } from '@edr/types';
|
||||
|
||||
import type { ChatConfig } from '../../config/chat.config';
|
||||
import { ChatBridgeService } from './chat-bridge.service';
|
||||
import type { MatrixClient } from './matrix.client';
|
||||
|
||||
const config: ChatConfig = {
|
||||
enabled: true,
|
||||
baseUrl: 'https://matrix.test',
|
||||
publicBaseUrl: 'https://matrix.test',
|
||||
webUrl: 'https://chat.test',
|
||||
serverName: 'matrix.test',
|
||||
jwtSecret: 'secret',
|
||||
adminToken: 'syt_whatever',
|
||||
};
|
||||
|
||||
function harness(overrides: Partial<ChatConfig> = {}) {
|
||||
const matrix = {
|
||||
ensureRoom: jest.fn(async (alias: string) => `!${alias}:matrix.test`),
|
||||
sendMessage: jest.fn(
|
||||
async (_roomId: string, _body: string, _html?: string) => undefined,
|
||||
),
|
||||
};
|
||||
const service = new ChatBridgeService(
|
||||
{ ...config, ...overrides },
|
||||
matrix as unknown as MatrixClient,
|
||||
);
|
||||
return { service, matrix };
|
||||
}
|
||||
|
||||
const notification = (type: NotificationType): NotifyInput =>
|
||||
({ type, title: 'Booking BK-1', body: 'needs review' }) as unknown as NotifyInput;
|
||||
|
||||
describe('ChatBridgeService', () => {
|
||||
it('posts every notification type into #freight-alerts', async () => {
|
||||
// This used to route REQUEST_SUBMITTED and CLEARANCE_REVIEW to a hardcoded
|
||||
// `dept-operation` alias, but the reconcile derives dept aliases from the
|
||||
// IAM position key (`edr_freight_app/opn` shaped), so nothing it created
|
||||
// ever matched. The bridge made its own empty room and posted there, where
|
||||
// no employee was a member.
|
||||
const { service, matrix } = harness();
|
||||
|
||||
for (const type of [
|
||||
NotificationType.REQUEST_SUBMITTED,
|
||||
NotificationType.CLEARANCE_REVIEW,
|
||||
NotificationType.GENERIC,
|
||||
]) {
|
||||
await service.bridge(notification(type));
|
||||
}
|
||||
|
||||
expect(new Set(matrix.ensureRoom.mock.calls.map(([alias]) => alias))).toEqual(
|
||||
new Set(['freight-alerts']),
|
||||
);
|
||||
expect(matrix.sendMessage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does nothing at all when chat is switched off', async () => {
|
||||
const { service, matrix } = harness({ enabled: false });
|
||||
|
||||
await service.bridge(notification(NotificationType.GENERIC));
|
||||
|
||||
expect(matrix.ensureRoom).not.toHaveBeenCalled();
|
||||
expect(matrix.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never lets a chat failure escape into the notification that triggered it', async () => {
|
||||
// Same contract as NotificationInboxService.notify(): bridging is
|
||||
// best-effort and must not roll back the caller's transaction.
|
||||
const { service, matrix } = harness();
|
||||
matrix.ensureRoom.mockRejectedValueOnce(new Error('Matrix POST ... -> 429'));
|
||||
|
||||
await expect(
|
||||
service.bridge(notification(NotificationType.GENERIC)),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,11 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
import { NotificationType, type NotifyInput } from '@edr/types';
|
||||
import type { NotifyInput } from '@edr/types';
|
||||
|
||||
import chatConfig from '../../config/chat.config';
|
||||
import { ALERTS_ROOM } from './chat-provisioning.service';
|
||||
import { MatrixClient } from './matrix.client';
|
||||
|
||||
const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' };
|
||||
|
||||
/**
|
||||
* Best-effort per-type routing to an existing dept room. Anything not listed
|
||||
* (including GENERIC) falls through to #freight-alerts — safer than a wrong
|
||||
* guess at which department a type belongs to. Extend as real usage shows
|
||||
* which types actually want a dept room instead of the shared feed.
|
||||
*
|
||||
* `name` matters only if this bridge is the very first thing to touch that
|
||||
* alias (normally the nightly/on-demand reconcile creates dept rooms first,
|
||||
* with the position's real name) — ensureRoom never renames an existing
|
||||
* room, so this must match what ChatProvisioningService would have used.
|
||||
*/
|
||||
const ROOM_FOR_TYPE: Partial<Record<NotificationType, { alias: string; name: string }>> = {
|
||||
[NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' },
|
||||
[NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirrors BACKOFFICE-audience notifications into chat so staff see them
|
||||
* without having the inbox open. Hooked once into
|
||||
@@ -31,6 +14,15 @@ const ROOM_FOR_TYPE: Partial<Record<NotificationType, { alias: string; name: str
|
||||
*
|
||||
* Gated on BACKOFFICE only: notify() also serves PORTAL (customer)
|
||||
* notifications, which must never land in an internal staff room.
|
||||
*
|
||||
* Everything goes to one room. This used to route REQUEST_SUBMITTED and
|
||||
* CLEARANCE_REVIEW to a hardcoded `dept-operation` alias — but the reconcile
|
||||
* derives dept aliases from the IAM position key, which is `edr_freight_app/opn`
|
||||
* shaped, so `#dept-operation` matched nothing it creates. The bridge quietly
|
||||
* created its own empty room and posted every notification into it, where no
|
||||
* employee was a member. A single room the reconcile actually populates beats
|
||||
* per-type routing that silently misses; add routing back when real usage asks
|
||||
* for it, keyed off the same derivation the reconcile uses.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatBridgeService {
|
||||
@@ -46,8 +38,9 @@ export class ChatBridgeService {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
try {
|
||||
const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM;
|
||||
const roomId = await this.matrix.ensureRoom(room.alias, room.name);
|
||||
// get-or-create as a safety net only: the reconcile creates this room
|
||||
// inside the space and joins every position holder to it.
|
||||
const roomId = await this.matrix.ensureRoom(ALERTS_ROOM.alias, ALERTS_ROOM.name);
|
||||
const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`;
|
||||
const html = `<strong>${escapeHtml(input.title)}</strong><br/>${escapeHtml(input.body)}${
|
||||
input.link ? `<br/><a href="${escapeHtml(input.link)}">${escapeHtml(input.link)}</a>` : ''
|
||||
|
||||
@@ -5,34 +5,57 @@ import type { DataSource } from 'typeorm';
|
||||
import { ChatProvisioningService } from './chat-provisioning.service';
|
||||
import type { MatrixClient } from './matrix.client';
|
||||
|
||||
const NAA = '03f5eb9e-23a0-4413-8d98-8de4b98b1be2';
|
||||
const SUPER_ADMIN = 'f1534714-fa4a-4780-a081-05d4c1f6c25f';
|
||||
const BOT = '@edrbot:m.test';
|
||||
|
||||
interface Holder {
|
||||
positionKey: string;
|
||||
positionName: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
const holder = (
|
||||
userId: string,
|
||||
userName: string,
|
||||
positionKey: string,
|
||||
positionName = positionKey,
|
||||
): Holder => ({ positionKey, positionName, userId, userName });
|
||||
|
||||
/**
|
||||
* `members` maps a room id to who Matrix currently reports as joined, so a
|
||||
* test can put a leaver in a room and watch what the reconcile does about it.
|
||||
*/
|
||||
function harness(holders: Holder[], members: Record<string, string[]> = {}) {
|
||||
const matrix = {
|
||||
mxidFor: jest.fn(
|
||||
(userId: string, name: string) => `@${name}.${userId.slice(0, 6)}:m.test`,
|
||||
),
|
||||
whoami: jest.fn(async () => BOT),
|
||||
ensureUser: jest.fn(async (_mxid: string, _name?: string) => undefined),
|
||||
ensureRoom: jest.fn(
|
||||
async (alias: string, _name?: string, _opts?: unknown) => `!${alias}:m.test`,
|
||||
),
|
||||
ensureJoined: jest.fn(async (_roomId: string, _mxid: string) => undefined),
|
||||
joinedMembers: jest.fn(async (roomId: string) => members[roomId] ?? [BOT]),
|
||||
kick: jest.fn(async (_roomId: string, _mxid: string, _reason: string) => undefined),
|
||||
lockUser: jest.fn(async (_mxid: string) => undefined),
|
||||
};
|
||||
const dataSource = { query: jest.fn(async () => holders) };
|
||||
const service = new ChatProvisioningService(
|
||||
dataSource as unknown as DataSource,
|
||||
matrix as unknown as MatrixClient,
|
||||
);
|
||||
return { service, matrix, dataSource };
|
||||
}
|
||||
|
||||
/**
|
||||
* `joinUserRooms` is the only thing standing between a first sign-in and an
|
||||
* empty Element — the reconcile that would otherwise fill the room list runs
|
||||
* nightly. Both branches below are outages that actually happened on dev.
|
||||
* nightly.
|
||||
*/
|
||||
describe('ChatProvisioningService.joinUserRooms', () => {
|
||||
const NAA = '03f5eb9e-23a0-4413-8d98-8de4b98b1be2';
|
||||
const SUPER_ADMIN = 'f1534714-fa4a-4780-a081-05d4c1f6c25f';
|
||||
|
||||
function harness(holders: unknown[]) {
|
||||
const matrix = {
|
||||
mxidFor: jest.fn(
|
||||
(userId: string, name: string) => `@${name}.${userId.slice(0, 6)}:m.test`,
|
||||
),
|
||||
ensureUser: jest.fn(async (_mxid: string, _name?: string) => undefined),
|
||||
ensureRoom: jest.fn(
|
||||
async (alias: string, _name?: string, _opts?: unknown) => `!${alias}:m.test`,
|
||||
),
|
||||
ensureJoined: jest.fn(async (_roomId: string, _mxid: string) => undefined),
|
||||
};
|
||||
const dataSource = { query: jest.fn(async () => holders) };
|
||||
const service = new ChatProvisioningService(
|
||||
dataSource as unknown as DataSource,
|
||||
matrix as unknown as MatrixClient,
|
||||
);
|
||||
return { service, matrix };
|
||||
}
|
||||
|
||||
it('creates nothing for a user holding no current position', async () => {
|
||||
// Super Admin on dev: three iam.employees rows, zero employee_positions.
|
||||
// Synapse still auto-registers the account on JWT login, so the only
|
||||
@@ -46,17 +69,12 @@ describe('ChatProvisioningService.joinUserRooms', () => {
|
||||
expect(matrix.ensureJoined).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('joins a position holder to the space, #general and their dept room', async () => {
|
||||
it('joins a holder to the space, #general, #freight-alerts and their dept room', async () => {
|
||||
const { service, matrix } = harness([
|
||||
{
|
||||
positionKey: 'edr_freight_app/marketer',
|
||||
positionName: 'Marketer',
|
||||
userId: NAA,
|
||||
userName: 'naa',
|
||||
},
|
||||
holder(NAA, 'naa', 'edr_freight_app/marketer', 'Marketer'),
|
||||
]);
|
||||
|
||||
await expect(service.joinUserRooms(NAA, 'naa')).resolves.toBe(2);
|
||||
await expect(service.joinUserRooms(NAA, 'naa')).resolves.toBe(4);
|
||||
|
||||
// The account has to exist before the admin join API will touch it — JWT
|
||||
// auto-registration happens after this runs.
|
||||
@@ -65,28 +83,112 @@ describe('ChatProvisioningService.joinUserRooms', () => {
|
||||
expect(matrix.ensureRoom.mock.calls.map(([alias]) => alias)).toEqual([
|
||||
'edr-freight',
|
||||
'general',
|
||||
'freight-alerts',
|
||||
'dept-edr_freight_app/marketer',
|
||||
]);
|
||||
|
||||
// The space itself is joined, not only the rooms under it: Element shows a
|
||||
// space in the left rail only to its members, so dropping this scatters
|
||||
// every dept room loose into Home.
|
||||
// every dept room loose into Home. #freight-alerts is joined here too, or
|
||||
// a new hire sees no bridged notification until the nightly reconcile.
|
||||
expect(matrix.ensureJoined.mock.calls.map(([roomId]) => roomId)).toEqual([
|
||||
'!edr-freight:m.test',
|
||||
'!general:m.test',
|
||||
'!freight-alerts:m.test',
|
||||
'!dept-edr_freight_app/marketer:m.test',
|
||||
]);
|
||||
});
|
||||
|
||||
it('scopes the position lookup to the one user', async () => {
|
||||
const { service } = harness([]);
|
||||
const { service, dataSource } = harness([]);
|
||||
await service.joinUserRooms(NAA, 'naa');
|
||||
// Without the third parameter this would reconcile the whole unit on every
|
||||
// click of "Open EDR Chat".
|
||||
const [sql, params] = (service as unknown as {
|
||||
dataSource: { query: jest.Mock };
|
||||
}).dataSource.query.mock.calls[0];
|
||||
const [sql, params] = dataSource.query.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown[],
|
||||
];
|
||||
expect(sql).toContain('AND e.user_id = $3');
|
||||
expect(params).toEqual(['edr_freight', 'edr_freight_app', NAA]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatProvisioningService.reconcile', () => {
|
||||
it('aborts instead of emptying every room when the holder query returns nothing', async () => {
|
||||
// Zero holders never means "every employee left at once" — it means the
|
||||
// query failed, the org/unit keys drifted, or a migration is mid-flight.
|
||||
// Acting on it would kick every member of every room and lock every
|
||||
// account, which is exactly the outage this guard exists to prevent.
|
||||
const { service, matrix } = harness([]);
|
||||
|
||||
await expect(service.reconcile()).rejects.toThrow(/no current position holders/i);
|
||||
|
||||
expect(matrix.kick).not.toHaveBeenCalled();
|
||||
expect(matrix.lockUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks a departed member rather than deactivating them', async () => {
|
||||
const leaver = '@gone.999999:m.test';
|
||||
const { service, matrix } = harness(
|
||||
[holder(NAA, 'naa', 'marketer', 'Marketer')],
|
||||
{
|
||||
'!edr-freight:m.test': [BOT, '@naa.03f5eb:m.test', leaver],
|
||||
'!general:m.test': [BOT, '@naa.03f5eb:m.test', leaver],
|
||||
'!freight-alerts:m.test': [BOT, '@naa.03f5eb:m.test'],
|
||||
'!dept-marketer:m.test': [BOT, '@naa.03f5eb:m.test'],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await service.reconcile();
|
||||
|
||||
expect(matrix.kick.mock.calls.map(([, mxid]) => mxid)).toEqual([leaver, leaver]);
|
||||
// Locking is reversible; deactivation is not, and on a homeserver with no
|
||||
// password login it cannot be undone at all.
|
||||
expect(matrix.lockUser).toHaveBeenCalledTimes(1);
|
||||
expect(matrix.lockUser).toHaveBeenCalledWith(leaver);
|
||||
expect(result.locked).toBe(1);
|
||||
});
|
||||
|
||||
it('does not lock someone who only moved between positions', async () => {
|
||||
const naaMxid = '@naa.03f5eb:m.test';
|
||||
// naa holds `marketer` now; the room for their old position still lists them.
|
||||
const { service, matrix } = harness(
|
||||
[
|
||||
holder(NAA, 'naa', 'marketer', 'Marketer'),
|
||||
holder('aaa04914-b7ee-47b3-9c63-4324046a26bd', 'nati', 'opn', 'Operation'),
|
||||
],
|
||||
{ '!dept-opn:m.test': [BOT, naaMxid, '@nati.aaa049:m.test'] },
|
||||
);
|
||||
|
||||
const result = await service.reconcile();
|
||||
|
||||
expect(matrix.kick).toHaveBeenCalledWith(
|
||||
'!dept-opn:m.test',
|
||||
naaMxid,
|
||||
expect.any(String),
|
||||
);
|
||||
// Kicked from one room, still current elsewhere — their account stays open.
|
||||
expect(matrix.lockUser).not.toHaveBeenCalled();
|
||||
expect(result.locked).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses to empty a populated room when its desired set is empty', async () => {
|
||||
// Per-room backstop for the paths the unit-level guard above cannot see.
|
||||
const { service, matrix } = harness([holder(NAA, 'naa', 'marketer')], {
|
||||
'!room:m.test': [BOT, '@naa.03f5eb:m.test', '@nati.aaa049:m.test'],
|
||||
});
|
||||
|
||||
const diff = await (
|
||||
service as unknown as {
|
||||
syncMembership: (
|
||||
roomId: string,
|
||||
desired: Set<string>,
|
||||
bot: string,
|
||||
) => Promise<{ joined: number; kicked: string[] }>;
|
||||
}
|
||||
).syncMembership('!room:m.test', new Set<string>(), BOT);
|
||||
|
||||
expect(diff).toEqual({ joined: 0, kicked: [] });
|
||||
expect(matrix.kick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@ const UNIT_KEY = 'edr_freight_app';
|
||||
const SPACE_ALIAS = 'edr-freight';
|
||||
const GENERAL_ALIAS = 'general';
|
||||
|
||||
/** Where ChatBridgeService mirrors backoffice notifications. Provisioned here,
|
||||
* with every position holder in it, so bridged messages land somewhere staff
|
||||
* actually are — the bridge only ever get-or-creates it as a safety net. */
|
||||
export const ALERTS_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' };
|
||||
|
||||
interface PositionHolder {
|
||||
positionKey: string;
|
||||
positionName: string;
|
||||
@@ -24,7 +29,8 @@ export interface ReconcileResult {
|
||||
rooms: number;
|
||||
joined: number;
|
||||
kicked: number;
|
||||
deactivated: number;
|
||||
/** Departed accounts locked — reversible. See {@link MatrixClient.lockUser}. */
|
||||
locked: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +61,7 @@ export class ChatProvisioningService {
|
||||
const result = await this.reconcile();
|
||||
this.logger.log(
|
||||
`Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` +
|
||||
`${result.kicked} kicked, ${result.deactivated} deactivated`,
|
||||
`${result.kicked} kicked, ${result.locked} locked`,
|
||||
);
|
||||
} catch (err) {
|
||||
// Never throws into the scheduler — chat provisioning must not be able
|
||||
@@ -122,6 +128,14 @@ export class ChatProvisioningService {
|
||||
parentSpaceId: spaceId,
|
||||
});
|
||||
await this.matrix.ensureJoined(generalRoomId, mxid);
|
||||
// Without this a new hire sees no bridged notification until the nightly
|
||||
// reconcile puts them in the alerts room.
|
||||
const alertsRoomId = await this.matrix.ensureRoom(
|
||||
ALERTS_ROOM.alias,
|
||||
ALERTS_ROOM.name,
|
||||
{ parentSpaceId: spaceId },
|
||||
);
|
||||
await this.matrix.ensureJoined(alertsRoomId, mxid);
|
||||
|
||||
for (const position of positions) {
|
||||
const roomId = await this.matrix.ensureRoom(
|
||||
@@ -132,10 +146,10 @@ export class ChatProvisioningService {
|
||||
await this.matrix.ensureJoined(roomId, mxid);
|
||||
}
|
||||
|
||||
return positions.length + 1;
|
||||
return positions.length + 3; // space + general + alerts
|
||||
}
|
||||
|
||||
/** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */
|
||||
/** Force-joins additions, kicks users no longer entitled to this room. */
|
||||
private async syncMembership(
|
||||
roomId: string,
|
||||
desiredUserIds: Set<string>,
|
||||
@@ -144,6 +158,19 @@ export class ChatProvisioningService {
|
||||
const current = await this.matrix.joinedMembers(roomId);
|
||||
const currentSet = new Set(current.filter((id) => id !== botMxid));
|
||||
|
||||
// An empty desired set against a populated room is not "everyone left" —
|
||||
// it is a query that failed, a key that drifted, or a migration caught
|
||||
// mid-flight. Acting on it would clear the room and then lock every
|
||||
// account that was in it. {@link reconcile} guards the same shape at the
|
||||
// unit level; this is the per-room backstop for the paths it cannot see.
|
||||
if (desiredUserIds.size === 0 && currentSet.size > 0) {
|
||||
this.logger.warn(
|
||||
`Refusing to empty room ${roomId}: desired membership is empty while ` +
|
||||
`${currentSet.size} member(s) are joined. Left untouched.`,
|
||||
);
|
||||
return { joined: 0, kicked: [] };
|
||||
}
|
||||
|
||||
let joined = 0;
|
||||
for (const userId of desiredUserIds) {
|
||||
if (!currentSet.has(userId)) {
|
||||
@@ -165,6 +192,17 @@ export class ChatProvisioningService {
|
||||
|
||||
async reconcile(): Promise<ReconcileResult> {
|
||||
const holders = await this.currentHolders();
|
||||
// The desired state for the whole unit. Empty means the IAM query failed,
|
||||
// the org/unit keys drifted, or a migration is mid-flight — it never means
|
||||
// every employee left at once. Continuing would kick every member of every
|
||||
// room and lock every account, so refuse the run and keep yesterday's
|
||||
// state, which is wrong at worst by a day.
|
||||
if (holders.length === 0) {
|
||||
throw new Error(
|
||||
`Chat reconcile aborted: no current position holders for ${ORG_KEY}/${UNIT_KEY}. ` +
|
||||
'Refusing to read that as "remove everyone".',
|
||||
);
|
||||
}
|
||||
const botMxid = await this.matrix.whoami();
|
||||
|
||||
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
|
||||
@@ -173,6 +211,11 @@ export class ChatProvisioningService {
|
||||
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
|
||||
parentSpaceId: spaceId,
|
||||
});
|
||||
const alertsRoomId = await this.matrix.ensureRoom(
|
||||
ALERTS_ROOM.alias,
|
||||
ALERTS_ROOM.name,
|
||||
{ parentSpaceId: spaceId },
|
||||
);
|
||||
|
||||
const allUserIds = new Set(
|
||||
holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)),
|
||||
@@ -189,12 +232,12 @@ export class ChatProvisioningService {
|
||||
await this.matrix.ensureUser(mxid, h.userName);
|
||||
}
|
||||
|
||||
let rooms = 2; // space + general
|
||||
let rooms = 3; // space + general + alerts
|
||||
let joined = 0;
|
||||
let kicked = 0;
|
||||
// A user kicked from anything while holding zero current positions
|
||||
// anywhere in the unit (allUserIds spans every position) is a full
|
||||
// leaver, not just moved between positions — deactivate their account.
|
||||
// leaver, not just moved between positions — lock their account.
|
||||
const kickedUserIds = new Set<string>();
|
||||
|
||||
// Space membership follows the org tree exactly like room membership —
|
||||
@@ -210,6 +253,11 @@ export class ChatProvisioningService {
|
||||
kicked += generalDiff.kicked.length;
|
||||
generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
|
||||
|
||||
const alertsDiff = await this.syncMembership(alertsRoomId, allUserIds, botMxid);
|
||||
joined += alertsDiff.joined;
|
||||
kicked += alertsDiff.kicked.length;
|
||||
alertsDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
|
||||
|
||||
const byPosition = new Map<string, { name: string; userIds: Set<string> }>();
|
||||
for (const h of holders) {
|
||||
const entry = byPosition.get(h.positionKey) ?? {
|
||||
@@ -232,19 +280,19 @@ export class ChatProvisioningService {
|
||||
diff.kicked.forEach((uid) => kickedUserIds.add(uid));
|
||||
}
|
||||
|
||||
let deactivated = 0;
|
||||
let locked = 0;
|
||||
for (const userId of kickedUserIds) {
|
||||
if (allUserIds.has(userId)) continue; // moved position, still current elsewhere
|
||||
try {
|
||||
await this.matrix.deactivateUser(userId);
|
||||
deactivated += 1;
|
||||
await this.matrix.lockUser(userId);
|
||||
locked += 1;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to deactivate departed user ${userId}: ${(err as Error).message}`,
|
||||
`Failed to lock departed user ${userId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { rooms, joined, kicked, deactivated };
|
||||
return { rooms, joined, kicked, locked };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,68 @@ describe('MatrixClient.verifyServerAdmin', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatrixClient.ensureUser', () => {
|
||||
it('lifts the lock on a returning employee', async () => {
|
||||
// A previous reconcile locked them as a leaver. Force-joining them back
|
||||
// into rooms while they still cannot log in is a silent half-restore.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
response(200, { name: '@naa.03f5eb:matrix.test', locked: true }),
|
||||
)
|
||||
.mockResolvedValueOnce(response(200, {}));
|
||||
|
||||
await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const [url, init] = fetchMock.mock.calls[1] as [string, { body: string }];
|
||||
expect(String(url)).toContain('/_synapse/admin/v2/users/');
|
||||
expect(JSON.parse(init.body)).toEqual({ locked: false });
|
||||
});
|
||||
|
||||
it('leaves an account that is not locked alone', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
response(200, { name: '@naa.03f5eb:matrix.test', locked: false }),
|
||||
);
|
||||
|
||||
await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatrixClient rate limiting', () => {
|
||||
it('retries a 429 after the delay Synapse asks for', async () => {
|
||||
// The dev outage: a reconcile is a burst of writes, Synapse throttled an
|
||||
// m.space.child PUT, and one un-retried 429 threw the whole run away.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
|
||||
.mockResolvedValueOnce(
|
||||
response(429, {
|
||||
errcode: 'M_LIMIT_EXCEEDED',
|
||||
error: 'Too Many Requests',
|
||||
retry_after_ms: 1,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(response(200, { users: [] }));
|
||||
|
||||
const check = await new MatrixClient(config).verifyServerAdmin();
|
||||
|
||||
expect(check.ok).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('gives up rather than hanging on a homeserver that only ever 429s', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
response(429, { errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 1 }),
|
||||
);
|
||||
|
||||
const check = await new MatrixClient(config).verifyServerAdmin();
|
||||
|
||||
expect(check.ok).toBe(false);
|
||||
expect(check.error).toContain('429');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatrixClient.adminCheck', () => {
|
||||
it('does not re-hit Synapse on every readiness probe', async () => {
|
||||
fetchMock
|
||||
|
||||
@@ -57,6 +57,9 @@ export class MatrixClient implements OnApplicationBootstrap {
|
||||
/** The token is a deploy-time fact and the readiness probe runs every few
|
||||
* seconds, so {@link adminCheck} memoises for this long. */
|
||||
private static readonly ADMIN_CHECK_TTL_MS = 5 * 60_000;
|
||||
/** Enough to ride out Synapse's limiter; short enough that a genuinely
|
||||
* wedged homeserver still fails the run rather than hanging it. */
|
||||
private static readonly MAX_RATE_LIMIT_RETRIES = 5;
|
||||
private adminCheckCache?: { at: number; result: AdminCheck };
|
||||
|
||||
constructor(
|
||||
@@ -95,13 +98,43 @@ export class MatrixClient implements OnApplicationBootstrap {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synapse answers a burst of writes with 429 + `retry_after_ms`, and a
|
||||
* reconcile is nothing but a burst of writes — one run creates the space,
|
||||
* #general and a room per position, then force-joins every holder into each.
|
||||
* The first run against dev tripped the limiter on an `m.space.child` PUT,
|
||||
* and because nothing retried, that single 429 threw the whole reconcile
|
||||
* away mid-flight. On the sign-in path ChatSsoService swallows the throw, so
|
||||
* the only visible symptom was an empty Element.
|
||||
*
|
||||
* Honour the delay Synapse asks for rather than guessing at one.
|
||||
*/
|
||||
private async fetchWithRetry(
|
||||
url: string,
|
||||
init: Parameters<typeof fetch>[1],
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const res = await fetch(url, init);
|
||||
if (res.status !== 429 || attempt >= MatrixClient.MAX_RATE_LIMIT_RETRIES) {
|
||||
return res;
|
||||
}
|
||||
// Body is discarded either way — this response is being retried.
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
retry_after_ms?: number;
|
||||
};
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, (Number(body.retry_after_ms) || 1000) + 100),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
token: string = this.config.adminToken,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -125,7 +158,7 @@ export class MatrixClient implements OnApplicationBootstrap {
|
||||
path: string,
|
||||
body: unknown,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -145,7 +178,7 @@ export class MatrixClient implements OnApplicationBootstrap {
|
||||
path: string,
|
||||
token?: string,
|
||||
): Promise<T | null> {
|
||||
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` },
|
||||
});
|
||||
@@ -333,11 +366,18 @@ export class MatrixClient implements OnApplicationBootstrap {
|
||||
* ("User not found") on an account that doesn't exist yet.
|
||||
*/
|
||||
async ensureUser(userId: string, displayName?: string): Promise<void> {
|
||||
const existing = await this.requestOrNull<{ name: string }>(
|
||||
const existing = await this.requestOrNull<{ name: string; locked?: boolean }>(
|
||||
'GET',
|
||||
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
|
||||
);
|
||||
if (existing) return;
|
||||
if (existing) {
|
||||
// A returning employee is still locked from the reconcile that saw them
|
||||
// leave. Force-joining them into rooms while they cannot log in is a
|
||||
// silent half-restore, and this is the one call that already knows the
|
||||
// flag — so undo it here rather than making the caller ask again.
|
||||
if (existing.locked) await this.setLocked(userId, false);
|
||||
return;
|
||||
}
|
||||
await this.request(
|
||||
'PUT',
|
||||
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
|
||||
@@ -377,12 +417,30 @@ export class MatrixClient implements OnApplicationBootstrap {
|
||||
);
|
||||
}
|
||||
|
||||
/** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */
|
||||
deactivateUser(userId: string): Promise<void> {
|
||||
/**
|
||||
* Lock a departed employee out of chat — reversible, unlike deactivation.
|
||||
*
|
||||
* This used to call `/_synapse/admin/v1/deactivate`. That revokes sessions
|
||||
* the same way but cannot be undone in any useful sense on this deployment:
|
||||
* reactivation wants a password, and `password_config.enabled: false` means
|
||||
* there is none to set. Room memberships do not come back either. One bad
|
||||
* reconcile — a half-applied IAM migration, a renamed org key — would have
|
||||
* destroyed every staff account that way, permanently.
|
||||
*
|
||||
* Locking blocks exactly the same access (Synapse rejects the account's
|
||||
* tokens with M_USER_LOCKED and refuses new logins) and is undone with a
|
||||
* single PUT — see {@link ensureUser}, which lifts it automatically when
|
||||
* someone comes back.
|
||||
*/
|
||||
lockUser(userId: string): Promise<void> {
|
||||
return this.setLocked(userId, true);
|
||||
}
|
||||
|
||||
private setLocked(userId: string, locked: boolean): Promise<void> {
|
||||
return this.request(
|
||||
'POST',
|
||||
`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`,
|
||||
{ erase: false },
|
||||
'PUT',
|
||||
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
|
||||
{ locked },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -174,6 +174,10 @@ export function DiffRow({
|
||||
from: string;
|
||||
to: string;
|
||||
}) {
|
||||
// No real "before" (field went from unset straight to a value, e.g. the
|
||||
// onboarding wizard's first save) — show the value alone rather than a
|
||||
// fake "— → value" that implies a prior state that never existed.
|
||||
const hadBefore = from !== "—";
|
||||
const changed = from !== to;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
@@ -181,24 +185,24 @@ export function DiffRow({
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
td={changed ? "line-through" : undefined}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{from}
|
||||
</Text>
|
||||
{changed && (
|
||||
<>
|
||||
<Text size="sm" c="edr-muted">
|
||||
→
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{to}
|
||||
</Text>
|
||||
</>
|
||||
{hadBefore && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
td={changed ? "line-through" : undefined}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{from}
|
||||
</Text>
|
||||
)}
|
||||
{hadBefore && changed && (
|
||||
<Text size="sm" c="edr-muted">
|
||||
→
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" fw={changed ? 600 : undefined} c={changed ? "edr-text" : "dimmed"}>
|
||||
{to}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -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. */}
|
||||
<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 />
|
||||
|
||||
<Tooltip label="Language" withArrow openDelay={300}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T>(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;
|
||||
|
||||
@@ -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