feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http';
|
||||
import { currentIdempotencyKey, httpClientFetch, withIdempotencyKey } from './api-client.provider';
|
||||
|
||||
/** Minimal stand-in for HttpClient — only `.request(...)` is ever called by the
|
||||
* adapter under test, so no TestBed/HttpClientTestingModule needed. */
|
||||
function fakeHttpClient(
|
||||
request: (method: string, url: string, options: { headers: Record<string, string> }) => unknown,
|
||||
): HttpClient {
|
||||
return { request } as unknown as HttpClient;
|
||||
}
|
||||
|
||||
describe('withIdempotencyKey / currentIdempotencyKey', () => {
|
||||
it('threads the key to every read made inside the wrapped fn', async () => {
|
||||
const seen: string[] = [];
|
||||
await withIdempotencyKey('fixed-key', async () => {
|
||||
seen.push(currentIdempotencyKey());
|
||||
seen.push(currentIdempotencyKey());
|
||||
});
|
||||
expect(seen).toEqual(['fixed-key', 'fixed-key']);
|
||||
});
|
||||
|
||||
it('clears the key once the wrapped fn settles', async () => {
|
||||
await withIdempotencyKey('fixed-key', async () => undefined);
|
||||
expect(currentIdempotencyKey()).not.toBe('fixed-key');
|
||||
});
|
||||
|
||||
it('falls back to a generated uuid-shaped key when none is pending', () => {
|
||||
expect(currentIdempotencyKey()).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('httpClientFetch', () => {
|
||||
it('sends the pending idempotency key as a header for a write, not a fresh one per attempt', async () => {
|
||||
let sentHeaders: Record<string, string> | undefined;
|
||||
const http = fakeHttpClient((_method, _url, opts) => {
|
||||
sentHeaders = opts.headers;
|
||||
return of(new HttpResponse({ status: 200, body: '' }));
|
||||
});
|
||||
|
||||
await withIdempotencyKey('logical-submit-key', () =>
|
||||
httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' }),
|
||||
);
|
||||
|
||||
expect(sentHeaders?.['Idempotency-Key']).toBe('logical-submit-key');
|
||||
});
|
||||
|
||||
// `http.request(...)` itself is only called once per `fetch()` — it returns a
|
||||
// cold Observable, and `retry` resubscribes to *that*, not to `.request()`
|
||||
// again (exactly how Angular's real HttpClient triggers a fresh network call
|
||||
// per subscription). So attempts are counted where the resubscription lands:
|
||||
// the `throwError` factory, not the outer mock call.
|
||||
it('retries a failing GET twice before giving up', async () => {
|
||||
let attempts = 0;
|
||||
const http = fakeHttpClient(() =>
|
||||
throwError(() => {
|
||||
attempts++;
|
||||
return new HttpErrorResponse({ status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await httpClientFetch(http).fetch('/api/v1/notes', { method: 'GET' });
|
||||
|
||||
expect(attempts).toBe(3); // 1 original + 2 retries
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it('never retries a failing write', async () => {
|
||||
let attempts = 0;
|
||||
const http = fakeHttpClient(() =>
|
||||
throwError(() => {
|
||||
attempts++;
|
||||
return new HttpErrorResponse({ status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' });
|
||||
|
||||
expect(attempts).toBe(1);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Provider } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { firstValueFrom, retry, timeout, TimeoutError } from 'rxjs';
|
||||
import { ApiClient, ProblemDetails } from './api-client';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
|
||||
/** Single place every API call passes through: the seam for cross-cutting concerns. */
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* A stable Idempotency-Key threaded down from the command layer (one per logical
|
||||
* submit — see `runSubmit`) rather than minted per HTTP attempt, so a retried
|
||||
* submit dedupes on the backend instead of double-submitting. The NSwag-generated
|
||||
* `ApiClient` has no per-call header hook, so `withIdempotencyKey` bridges it here:
|
||||
* every non-GET call made synchronously inside `fn` picks up the same key.
|
||||
* ponytail: a module-level variable, not a proper async-context primitive — holds
|
||||
* up because every submit command calls its adapter synchronously (no await
|
||||
* before reaching this file); swap for `AsyncLocal`-equivalent if concurrent
|
||||
* submits ever become possible.
|
||||
*/
|
||||
let pendingIdempotencyKey: string | undefined;
|
||||
|
||||
export function withIdempotencyKey<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
pendingIdempotencyKey = key;
|
||||
return fn().finally(() => (pendingIdempotencyKey = undefined));
|
||||
}
|
||||
|
||||
export function currentIdempotencyKey(): string {
|
||||
return pendingIdempotencyKey ?? crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
|
||||
* client expects, so every API call flows through HttpClient interceptors (the
|
||||
* `?scenario=` toggle) and the cross-cutting concerns below. The generated client
|
||||
* is the only place HTTP shapes are known; this is the only place it meets
|
||||
* Angular's HTTP stack — i.e. the one seam to add:
|
||||
* - timeout (done — REQUEST_TIMEOUT_MS),
|
||||
* - correlation id (done — X-Correlation-Id, echoed in backend logs),
|
||||
* - idempotency key for writes (done — Idempotency-Key, stable per logical
|
||||
* submit via `withIdempotencyKey`/`runSubmit`, so a retry dedupes),
|
||||
* - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
|
||||
* - retry/backoff (done — GET only, `retry({ count: 2, delay: 500 })`; writes are
|
||||
* never auto-retried, which is exactly what makes the idempotency key above
|
||||
* matter only for a future/manual retry, not routine traffic).
|
||||
*/
|
||||
export function httpClientFetch(http: HttpClient) {
|
||||
return {
|
||||
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const headers: Record<string, string> = {
|
||||
...((init?.headers ?? {}) as Record<string, string>),
|
||||
'X-Correlation-Id': crypto.randomUUID(),
|
||||
};
|
||||
if (method !== 'GET') headers['Idempotency-Key'] = currentIdempotencyKey();
|
||||
try {
|
||||
const request$ = http
|
||||
.request(method, url as string, {
|
||||
body: init?.body as string | undefined,
|
||||
headers,
|
||||
observe: 'response',
|
||||
responseType: 'text',
|
||||
})
|
||||
.pipe(timeout(REQUEST_TIMEOUT_MS));
|
||||
const res = await firstValueFrom(
|
||||
method === 'GET' ? request$.pipe(retry({ count: 2, delay: 500 })) : request$,
|
||||
);
|
||||
// 204/205/304 are null-body statuses — new Response(body, …) throws for any non-null body.
|
||||
const nullBody = res.status === 204 || res.status === 205 || res.status === 304;
|
||||
return new Response(nullBody ? null : (res.body ?? ''), { status: res.status || 200 });
|
||||
} catch (e) {
|
||||
if (e instanceof TimeoutError) return new Response('', { status: 504 });
|
||||
const err = e as HttpErrorResponse;
|
||||
const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {});
|
||||
// ponytail: clamp to a Response-constructible status (an aborted/interceptor
|
||||
// request reports status 0, which `new Response` rejects).
|
||||
const status = err.status >= 200 && err.status <= 599 ? err.status : 500;
|
||||
return new Response(body, { status });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Provide a root ApiClient that talks through HttpClient. Base URL comes from the
|
||||
* environment (relative '' in dev → proxy; configurable per deployment). */
|
||||
export function provideApiClient(): Provider {
|
||||
return {
|
||||
provide: ApiClient,
|
||||
useFactory: (http: HttpClient) => new ApiClient(environment.apiBaseUrl, httpClientFetch(http)),
|
||||
deps: [HttpClient],
|
||||
};
|
||||
}
|
||||
|
||||
export type { ProblemDetails };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { problemDetail, problemFieldErrors } from './api-error';
|
||||
|
||||
describe('problemDetail', () => {
|
||||
it('extracts the detail from an RFC-7807 ProblemDetails', () => {
|
||||
expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe(
|
||||
'Afgewezen: 0 uren.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back when there is no detail', () => {
|
||||
expect(problemDetail(new Error('boom'), 'fallback')).toBe('fallback');
|
||||
expect(problemDetail({ status: 500 }, 'fallback')).toBe('fallback');
|
||||
expect(problemDetail(undefined, 'fallback')).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('problemFieldErrors (G4 seam)', () => {
|
||||
it('maps a ValidationProblemDetails errors dict to first-message-per-field', () => {
|
||||
expect(
|
||||
problemFieldErrors({ errors: { straat: ['Verplicht.'], postcode: ['Ongeldig.', 'x'] } }),
|
||||
).toEqual({ straat: 'Verplicht.', postcode: 'Ongeldig.' });
|
||||
});
|
||||
|
||||
it('returns {} when there is no errors envelope (the current backend shape)', () => {
|
||||
expect(problemFieldErrors({ detail: 'one banner' })).toEqual({});
|
||||
expect(problemFieldErrors(new Error('boom'))).toEqual({});
|
||||
expect(problemFieldErrors(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ProblemDetails } from './api-client';
|
||||
|
||||
/**
|
||||
* Extract a human-readable message from a rejected API call. A 4xx/5xx with a
|
||||
* ProblemDetails body (RFC 7807) is thrown by the generated client as the parsed
|
||||
* object; anything else falls back to the given message.
|
||||
*/
|
||||
export function problemDetail(e: unknown, fallback: string): string {
|
||||
if (e && typeof e === 'object' && 'detail' in e) {
|
||||
const detail = (e as ProblemDetails).detail;
|
||||
if (typeof detail === 'string' && detail) return detail;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEAM (G4): map a server validation envelope to field-level errors.
|
||||
*
|
||||
* ASP.NET's ValidationProblemDetails carries `errors: { field: string[] }`. The
|
||||
* backend today returns only `detail` (one banner message), so this returns `{}`.
|
||||
* When the backend starts sending `errors`, a machine's `SubmitFailed` handler can
|
||||
* merge this into its own `errors` map — the field-keyed shape the wizards already
|
||||
* render — so a rejection shows inline per field, not just as a banner. The
|
||||
* consumer hook is the only thing left to wire; the contract boundary lives here.
|
||||
*/
|
||||
export function problemFieldErrors(e: unknown): Record<string, string> {
|
||||
if (!e || typeof e !== 'object' || !('errors' in e)) return {};
|
||||
const errors = (e as { errors?: unknown }).errors;
|
||||
if (!errors || typeof errors !== 'object') return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [field, msgs] of Object.entries(errors as Record<string, unknown>)) {
|
||||
const first = Array.isArray(msgs) ? msgs[0] : msgs;
|
||||
if (typeof first === 'string') out[field] = first;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { stripDevParams } from './dev-params';
|
||||
|
||||
describe('stripDevParams (WP-37)', () => {
|
||||
it('removes ?scenario and ?role so the stored dev value wins on reload', () => {
|
||||
expect(stripDevParams('http://localhost:4200/dashboard?scenario=slow&role=admin')).toBe(
|
||||
'http://localhost:4200/dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps unrelated query params and the path/hash', () => {
|
||||
expect(stripDevParams('http://localhost:4200/beheer/zaken?scenario=error&tab=2#top')).toBe(
|
||||
'http://localhost:4200/beheer/zaken?tab=2#top',
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op when neither param is present', () => {
|
||||
expect(stripDevParams('http://localhost:4200/dashboard')).toBe(
|
||||
'http://localhost:4200/dashboard',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Remove the dev-only `?scenario=` and `?role=` params from a URL (WP-37). Once the
|
||||
* dev switcher (debug-state) has been used, sessionStorage is the authoritative source
|
||||
* for both — `currentScenario()`/`currentRole()` read the URL FIRST, so a stale param
|
||||
* left in the address bar would override the switcher on reload (the "stuck on slow"
|
||||
* bug). Stripping the params before reload lets the stored value win. Pure: returns the
|
||||
* rewritten href, mutates nothing.
|
||||
*/
|
||||
export function stripDevParams(href: string): string {
|
||||
const url = new URL(href);
|
||||
url.searchParams.delete('scenario');
|
||||
url.searchParams.delete('role');
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { FeatureFlag } from '@shared/domain/feature-flag';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for feature flags (WP-47): `GET /flags` (resolved set, drives FE gating)
|
||||
* and the admin `PUT /admin/flags/{key}`. The single place the ApiClient lives for flags; the
|
||||
* store parses at the boundary.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FeatureFlagsAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
list() {
|
||||
return this.client.flagsAll();
|
||||
}
|
||||
set(key: string, enabled: boolean) {
|
||||
return this.client.flags(key, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust-boundary parse of the flag set. */
|
||||
export function parseFlags(json: unknown): Result<string, FeatureFlag[]> {
|
||||
if (!Array.isArray(json)) return err('flags: not an array');
|
||||
const out: FeatureFlag[] = [];
|
||||
for (const f of json) {
|
||||
if (typeof f !== 'object' || f === null) return err('flags: row not an object');
|
||||
const d = f as Partial<FeatureFlag>;
|
||||
if (typeof d.key !== 'string' || typeof d.enabled !== 'boolean') return err('flags: bad shape');
|
||||
out.push({
|
||||
key: d.key,
|
||||
description: typeof d.description === 'string' ? d.description : '',
|
||||
enabled: d.enabled,
|
||||
});
|
||||
}
|
||||
return ok(out);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMe } from './me.adapter';
|
||||
|
||||
describe('parseMe (trust boundary)', () => {
|
||||
it('parses a known capability list', () => {
|
||||
const r = parseMe({ capabilities: ['brief:approve', 'brief:reject', 'brief:send'] });
|
||||
expect(r).toEqual({ ok: true, value: ['brief:approve', 'brief:reject', 'brief:send'] });
|
||||
});
|
||||
|
||||
it('parses an empty list (drafter — no capabilities)', () => {
|
||||
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
|
||||
});
|
||||
|
||||
it('recognizes the admin org-template capability (WP-23)', () => {
|
||||
expect(parseMe({ capabilities: ['orgtemplate:edit'] })).toEqual({
|
||||
ok: true,
|
||||
value: ['orgtemplate:edit'],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unrecognized capability strings instead of rejecting the response', () => {
|
||||
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
|
||||
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
|
||||
});
|
||||
|
||||
it('rejects malformed responses instead of trusting them', () => {
|
||||
expect(parseMe(null).ok).toBe(false);
|
||||
expect(parseMe({}).ok).toBe(false);
|
||||
expect(parseMe({ capabilities: 'brief:approve' }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable, inject, resource } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
|
||||
const KNOWN: readonly Capability[] = [
|
||||
'brief:approve',
|
||||
'brief:reject',
|
||||
'brief:send',
|
||||
'orgtemplate:edit',
|
||||
'stamdata:edit',
|
||||
'cases:manage',
|
||||
'flags:manage',
|
||||
];
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for `GET /me` (PRD-0002 §6): the current principal's
|
||||
* coarse, role-derived capabilities — nav/menu-level, not tied to any one screen's
|
||||
* live status (contrast a screen's own decision DTO, e.g. `BriefViewDto.decisions`).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MeAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
meResource() {
|
||||
return resource({ loader: () => this.client.me() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust-boundary parse. An unrecognized capability string is dropped rather than
|
||||
* rejecting the whole response — deny-by-default already covers it (AccessStore.can
|
||||
* returns false for anything not in the set), and it lets the backend grow the
|
||||
* capability list without breaking an older FE build.
|
||||
*/
|
||||
export function parseMe(json: unknown): Result<string, Capability[]> {
|
||||
if (typeof json !== 'object' || json === null) return err('me: not an object');
|
||||
const dto = json as { capabilities?: unknown };
|
||||
if (!Array.isArray(dto.capabilities)) return err('me: missing/invalid capabilities');
|
||||
return ok(dto.capabilities.filter((c): c is Capability => KNOWN.includes(c as Capability)));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { roleInterceptor } from './role.interceptor';
|
||||
|
||||
// currentRole() reads window.location.search; set it via the real URL rather than
|
||||
// vi.mock (the Angular unit-test system forbids mocking relative imports).
|
||||
beforeEach(() => window.history.replaceState({}, '', '/?role=admin'));
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
sessionStorage.clear(); // currentRole() now persists the dev role; don't leak across tests
|
||||
});
|
||||
|
||||
// Minimal stand-in for HttpRequest — the interceptor only reads `url` and calls
|
||||
// `clone({ setHeaders })`. Avoids importing @angular/common/http (its XHR chunk needs
|
||||
// the JIT compiler under vitest).
|
||||
function fakeReq(url: string) {
|
||||
const make = (headers: Map<string, string>) => ({
|
||||
url,
|
||||
headers,
|
||||
clone(opts: { setHeaders: Record<string, string> }) {
|
||||
const next = new Map(headers);
|
||||
for (const [k, v] of Object.entries(opts.setHeaders)) next.set(k, v);
|
||||
return make(next);
|
||||
},
|
||||
});
|
||||
return make(new Map());
|
||||
}
|
||||
|
||||
/** Run the interceptor and return the request it forwarded to `next`. */
|
||||
function forward(url: string) {
|
||||
let seen!: ReturnType<typeof fakeReq>;
|
||||
const next = (r: ReturnType<typeof fakeReq>) => {
|
||||
seen = r;
|
||||
return undefined;
|
||||
};
|
||||
// Cast: the fake matches the shape the interceptor actually touches.
|
||||
(roleInterceptor as unknown as (req: unknown, next: unknown) => unknown)(fakeReq(url), next);
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe('roleInterceptor', () => {
|
||||
it.each([
|
||||
'/api/v1/brief',
|
||||
'/api/v1/admin/org-template',
|
||||
'/api/v1/stamdata', // WP-29: the admin stamdata reads 403 without X-Role
|
||||
'/api/v1/stamdata/professions?peildatum=1999-01-01',
|
||||
'/api/v1/me',
|
||||
])('stamps X-Role on the role-aware endpoint %s', (url) => {
|
||||
expect(forward(url).headers.get('X-Role')).toBe('admin');
|
||||
});
|
||||
|
||||
it('leaves an unrelated endpoint untouched', () => {
|
||||
expect(forward('/api/v1/duo/diplomas').headers.has('X-Role')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { currentRole } from './role';
|
||||
|
||||
/**
|
||||
* Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role`
|
||||
* header so the backend can enforce the drafter/approver/admin rules. Only the
|
||||
* brief, org-template, stamdata and /me endpoints carry it (WP-23 widened the set —
|
||||
* /me must see the role or `AccessStore` could never learn a capability; WP-29 added
|
||||
* /stamdata, whose admin-only reads 403 without it); everything else is untouched.
|
||||
* A new admin-gated endpoint MUST be added here or its page silently 403s.
|
||||
*/
|
||||
const ROLE_AWARE = [
|
||||
'/api/v1/brief',
|
||||
'/api/v1/admin/org-template',
|
||||
'/api/v1/admin/cases',
|
||||
'/api/v1/admin/audit',
|
||||
'/api/v1/admin/flags',
|
||||
'/api/v1/stamdata',
|
||||
'/api/v1/me',
|
||||
];
|
||||
|
||||
export const roleInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!ROLE_AWARE.some((prefix) => req.url.includes(prefix))) return next(req);
|
||||
return next(req.clone({ setHeaders: { 'X-Role': currentRole() } }));
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Role } from '@shared/domain/role';
|
||||
|
||||
/**
|
||||
* Dev-only role stand-in (the reading MECHANISM; the `Role` type is domain). This
|
||||
* POC has one faked self-service user and no real identities, so the two-person
|
||||
* letter workflow (drafter vs approver) plus admin is driven by a `?role=` query
|
||||
* param. The backend receives it as an `X-Role` header (see role.interceptor),
|
||||
* resolves it into a `Principal` server-side, and is the sole authority on what that
|
||||
* principal may do (PRD-0002 phase P1, `Authz.Can`) — the FE only renders the
|
||||
* resulting decision flags, it no longer derives permission from this value itself.
|
||||
*
|
||||
* **Sticky within the tab (sessionStorage):** the interceptor reads this per request,
|
||||
* but navigation drops the query param (login redirects to /dashboard, RouterLinks
|
||||
* don't carry it), which would silently revert an admin to drafter mid-session and
|
||||
* 403 the admin endpoints. So a `?role=` seen in the URL is remembered for the tab;
|
||||
* later requests use the remembered value. Set `?role=drafter` (or a fresh tab) to
|
||||
* reset. Dev-only — the interceptor itself is only wired under `isDevMode()`.
|
||||
*/
|
||||
const STORAGE_KEY = 'dev-role';
|
||||
export const ROLES: readonly Role[] = ['drafter', 'approver', 'admin'];
|
||||
const isRole = (v: string | null): v is Role => !!v && ROLES.includes(v as Role);
|
||||
|
||||
export function currentRole(): Role {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('role');
|
||||
if (isRole(fromUrl)) {
|
||||
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
||||
return fromUrl;
|
||||
}
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return isRole(stored) ? stored : 'drafter';
|
||||
}
|
||||
|
||||
/** Dev switcher entry point: persist the chosen role for the tab (WP-33). */
|
||||
export function setRole(r: Role): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, r);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
||||
import { of, switchMap, throwError, timer } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { currentScenario } from './scenario';
|
||||
|
||||
/**
|
||||
* Demo-only: rewrites the timing/outcome of API data requests based on
|
||||
* ?scenario= so loading / empty / error states can be shown on demand.
|
||||
* Non-API requests are untouched.
|
||||
*/
|
||||
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!req.url.includes('/api/')) return next(req);
|
||||
|
||||
switch (currentScenario()) {
|
||||
case 'slow':
|
||||
return next(req).pipe(delay(2500));
|
||||
case 'loading':
|
||||
return next(req).pipe(delay(600_000)); // effectively never resolves
|
||||
case 'empty':
|
||||
// '[]' so the typed client parses it to an empty array (notes → Empty state).
|
||||
return of(new HttpResponse({ status: 200, body: '[]' })).pipe(delay(400));
|
||||
case 'error':
|
||||
return timer(400).pipe(
|
||||
switchMap(() =>
|
||||
throwError(
|
||||
() => new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }),
|
||||
),
|
||||
),
|
||||
);
|
||||
default:
|
||||
return next(req);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { currentScenario, setScenario } from './scenario';
|
||||
|
||||
const setUrl = (search: string) => history.pushState({}, '', search || '/');
|
||||
|
||||
describe('scenario (dev mechanism)', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
setUrl('/');
|
||||
});
|
||||
|
||||
it('reads a valid ?scenario= from the URL and persists it for the tab', () => {
|
||||
setUrl('?scenario=error');
|
||||
expect(currentScenario()).toBe('error');
|
||||
setUrl('/'); // navigation drops the query param — value stays sticky
|
||||
expect(currentScenario()).toBe('error');
|
||||
});
|
||||
|
||||
it('falls back to default when nothing is set or the value is invalid', () => {
|
||||
expect(currentScenario()).toBe('default');
|
||||
setUrl('?scenario=nonsense');
|
||||
expect(currentScenario()).toBe('default');
|
||||
});
|
||||
|
||||
it('setScenario persists the chosen scenario', () => {
|
||||
setScenario('slow');
|
||||
expect(currentScenario()).toBe('slow');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export type Scenario =
|
||||
| 'default'
|
||||
| 'slow'
|
||||
| 'loading'
|
||||
| 'empty'
|
||||
| 'error'
|
||||
// upload-only (the multipart POST is hand-written XHR, so it bypasses the HTTP
|
||||
// interceptor — these are simulated in upload.adapter.ts instead):
|
||||
| 'upload-slow'
|
||||
| 'upload-fail';
|
||||
|
||||
export const SCENARIOS: readonly Scenario[] = [
|
||||
'default',
|
||||
'slow',
|
||||
'loading',
|
||||
'empty',
|
||||
'error',
|
||||
'upload-slow',
|
||||
'upload-fail',
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'dev-scenario';
|
||||
const isScenario = (v: string | null): v is Scenario => !!v && SCENARIOS.includes(v as Scenario);
|
||||
|
||||
/**
|
||||
* Reads the active demo scenario so a demo can force each async state.
|
||||
* Sticky within the tab (sessionStorage), mirroring `role.ts`: a `?scenario=` in the
|
||||
* URL sets it; later navigation (which drops the query param) keeps the remembered
|
||||
* value. Set `?scenario=default`, use the dev switcher, or open a fresh tab to reset.
|
||||
* Dev-only — the interceptor that consumes this is wired only under `isDevMode()`.
|
||||
*/
|
||||
export function currentScenario(): Scenario {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('scenario');
|
||||
if (isScenario(fromUrl)) {
|
||||
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
||||
return fromUrl;
|
||||
}
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return isScenario(stored) ? stored : 'default';
|
||||
}
|
||||
|
||||
/** Dev switcher entry point: persist the chosen scenario for the tab (WP-33). */
|
||||
export function setScenario(s: Scenario): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, s);
|
||||
}
|
||||
Reference in New Issue
Block a user