Files
register-referentie/docs/frontend-decisions.md
T
not 0fe4813388 docs(portals): ADR-0034 — Caddy serves the portals (refs #166)
Records the decision, the directive-order footgun that shapes the Caddyfiles, and
the measured cost (the images grew 75.7 MB → 90.6 MB). Also updates the three
frontend-decisions entries and the two other docs that named nginx.
2026-09-04 17:25:29 +02:00

12 KiB

Frontend decisions

A running log of frontend tooling and component decisions (CLAUDE.md §10). One entry per decision; record why, and note any deviation from NL Design System.


Workspace & tooling (S-08a, #65)

The portals live in an Nx monorepo at the repository root, alongside the .NET services/.

  • Package manager: pnpm. Native build scripts are approved explicitly in pnpm-workspace.yaml under allowBuilds (pnpm 11 fails the install otherwise). Node 24, pnpm 11.
  • Angular, standalone components + signals, no NgModules (§10). Apps are generated with @nx/angular:application.
  • Unit tests: Vitest via Angular's built-in @angular/build:unit-test (the vitest-angular runner). Angular Testing Library is added for component tests when the first real components land (S-08c); the S-08a placeholder uses a plain TestBed render assertion.
  • Lint: ESLint (flat config, @nx/eslint).
  • Nx is scoped to apps/ + libs/ only. The @nx/docker and @nx/dotnet plugins are not installed — the .NET services are built by dotnet/the Makefile, and @nx/docker would otherwise infer every services/*/Dockerfile as an unnamed Nx project and break the project graph.
  • No Nx Cloud. nxCloudId is stripped from nx.json; remote caching would depend on an external service, and the repo is Gitea-only (§8.7). Nx's "configure-ai-agents" additions (.claude/settings.json, a CLAUDE.md section referencing a GitHub marketplace) are not committed for the same reason.
  • CI: a frontend job (make frontendpnpm install --frozen-lockfile + nx run-many -t lint test build) runs on pnpm + Node, with pinned action URLs (§15).

NL Design System: not yet introduced — the S-08a app is a placeholder. NL DS components arrive with the submit form (S-08c, #67); any deviation from NL DS will be recorded here.


API client generator (S-08b, #66)

libs/api-client is generated from services/bff/openapi.json — never hand-written (§10).

  • Generator: orval (client: 'angular'), a node-based generator (no Java, unlike openapi-generator), so it runs in the pnpm/Node CI lane. It emits an injectable BffApiV1Service using Angular's HttpClient — which means the DigiD bearer token can be attached by an HttpInterceptor (S-08c), the idiomatic Angular approach; a fetch-based SDK would bypass the interceptor pipeline.
  • Config: libs/api-client/orval.config.ts (single-file output into src/lib/generated/, clean: true, prettier). Regenerate with nx run api-client:generate after the BFF spec changes; the output is deterministic (idempotent), and src/lib/generated/ is never hand-edited.
  • Tested against a mocked BFF via HttpClientTesting (libs/api-client/src/lib/bff-api.spec.ts).
  • The BFF endpoints carry no operationId, so orval synthesises method names (postSelfServiceRegistrations, getOpenbaarRegister); adding explicit operation ids to the BFF is a possible later polish.

Self-service form: NL DS, DigiD auth, testing (S-08c, #67)

  • NL Design System via @utrecht/component-library-angular (libs/ui) + @utrecht/design-tokens (imported once in apps/self-service/src/styles.css). Utrecht is NL DS's reference Angular implementation. Its v3 components are NgModule-based, not standalone, so libs/ui re-exports UtrechtComponentsModule (and the component classes, so the AOT compiler resolves the template directives through the barrel); standalone components consume it via imports: [UtrechtComponentsModule]. §10's "no NgModules in new code" governs our code — consuming a third-party module is fine.
  • DigiD login via angular-auth-oidc-client (libs/auth): auth-code + PKCE against the Keycloak digid realm (public client big-portal). A small AuthService abstraction (bsn / isAuthenticated / login) wraps the library so components and the authenticatedGuard depend on a mockable surface; a token HttpInterceptor attaches the bearer to BFF calls (secure route). The OIDC authority/secureApiOrigin are dev defaults in app.config.ts; the compose-served app overrides them (S-08d), and the browser-vs-container issuer alignment is handled there (ADR-0010).
  • Testing: component tests use @testing-library/angular (§10) with AuthService and the api-client mocked; the axe (vitest-axe) check runs scoped to WCAG 2.1 AA tags (wcag2a/2aa/21a/21aa) with the document lang set, asserting zero violations on the submit page. The real DigiD browser round-trip is exercised in S-08d (Playwright).
  • Module boundaries: replaced the demo eslint depConstraints (scope:shop/scope:shared, left over from the Nx angular template) with a permissive * default; scope/type tags can be introduced when the portal set grows.

Serving + e2e (S-08d, #68)

  • Served by Caddy, same-origin as the BFF. The compose self-service image serves the built app and reverse-proxies /self-service/* + /openbaar/* to the bff service. Because the api-client uses relative URLs, the browser calls the app's own origin → Caddy forwards to the BFF: no CORS, and the DigiD token (same-origin) is attached by the interceptor. Caddy dials the BFF per request through the system resolver, so it starts before the BFF is up, picks up its restarts, and resolves the bare bff name on every engine — compose, podman and Kubernetes (ADR-0034; the Caddyfile sits next to each app's Dockerfile).
  • Runtime config. The app fetches /config.json before bootstrap (main.ts); appConfig is a factory. The dev default (public/config.json) points at localhost:8180; the Docker image bakes the compose value (keycloak:8080). One build, per-environment OIDC authority.
  • e2e runs inside the compose network. infra/run-e2e-check.sh runs Playwright in a container on cg, so the browser reaches Keycloak as keycloak:8080 — the same issuer the BFF validates against (resolves the browser-vs-container mismatch, ADR-0010). It uses the official mcr.microsoft.com/playwright:<version> image with browsers pre-baked, rather than downloading ~150 MB of Chromium on every run (issue #73) — the image tag is kept in lockstep with tests/e2e/package.json's @playwright/test version. The spec is copied in (docker cp), not mounted, so it leaves nothing root-owned on the host. Wired as verify-e2e in the verify-stack CI job.
  • e2e treats the portal origin as secure. In-network the portal is served over plain HTTP on a non-localhost origin (http://self-service), which is not a secure context, so Web Crypto (crypto.subtle) is unavailable. angular-auth-oidc-client needs it for the PKCE code challenge, so authorize() throws and the login redirect never fires. Production runs behind HTTPS where this is a non-issue; rather than terminate TLS in the throwaway stack, the Playwright config passes --unsafely-treat-insecure-origin-as-secure (honoured only by the full channel: 'chromium' build, not the default headless-shell). This emulates the production HTTPS secure context without touching the app or its production config.
  • tests/e2e is a standalone Playwright project (its own package.json), not an Nx project — it's a live-stack check like the other verify-* runners, not part of the frontend unit lane.

Openbaar Register portal (S-09, #10)

  • Anonymous, no auth. The openbaar register is a public read, so apps/openbaar has no angular-auth-oidc-client, no interceptor, and no config.jsonmain.ts bootstraps appConfig directly with just provideHttpClient + provideRouter. This is the deliberate contrast to self-service and keeps the app trivially cacheable/CDN-able.
  • Same-origin via Caddy, like self-service. The compose openbaar image serves the built app and reverse-proxies /openbaar to the BFF; the api-client's relative calls stay same-origin (no CORS). Served on :8141, health-checked over IPv4 (127.0.0.1), no Keycloak dependency.
  • Public-safe by construction. The portal only ever sees the BFF's OpenbaarProjection.PublicView (id + status); bsn/naam never leave the BFF. The e2e asserts the bsn never renders.
  • Loads on open, filters on search. RegisterPage fetches the full register on construction and re-queries /openbaar/register?q= on search — no client-side filtering, the BFF owns the query.

Behandel portal (S-12, #13)

The staff portal where a behandelaar works the werkbak (registrations awaiting beoordeling) and decides each — goedkeuren or afwijzen. apps/behandel mirrors apps/self-service; the net-new frontend work is the medewerker realm auth and the werkbak/decide page. Wiring rationale is in ADR-0013; this entry records the frontend-specific choices.

  • Medewerker realm auth, reusing libs/auth. Staff authenticate against the Keycloak medewerker realm (public client big-portal), not digid. Rather than fork the auth lib, the abstract AuthService grew a roles/hasRole surface (empty for realms without roles, e.g. digid), and a parallel MedewerkerAuthService + provideMedewerkerAuth were added — same auth-code + PKCE config, bound to the medewerker realm, reading the nested realm_access.roles claim. The library's own authInterceptor attaches the token to the relative /behandel/ calls (secure route), exactly as self-service does for /self-service/.
  • Roles reach the frontend via a realm mapper. Keycloak emits realm roles in the access token by default but not the ID token/userinfo the SPA reads, so the medewerker big-portal client gets a realm-roles protocol mapper (realm_access.roles, added to id + userinfo tokens). The BFF remains the security boundary (behandelaar policy, 401/403 on /behandel/*, ADR-0013); the frontend role signal is for display/UX, and the werkbak page surfaces a load failure (e.g. a 403 for a non-behandelaar) rather than swallowing it.
  • Same-origin via Caddy, like the other portals. The compose behandel image serves the built app and reverse-proxies /behandel to the BFF (relative calls, no CORS). Served on :8142, health-checked over IPv4 (127.0.0.1), depends on Keycloak for the medewerker realm.
  • Werkbak = decide-and-refresh. WerkbakPage loads GET /behandel/werkbak on open and renders a row per registration (referentie/bsn/status). Goedkeuren/afwijzen POST /behandel/registrations/ {id}/decide and then reload the werkbak, so the handled item drops off (its Flowable Beoordelen task is completed). Per-row decide buttons carry an aria-label including the reference, so the e2e (and screen readers) can target a specific registration in a shared werkbak.
  • Testing. Component tests use @testing-library/angular with BffApiV1Service/AuthService mocked and the axe WCAG 2.1 AA check; an app.config.spec drives the real interceptor + api-client to assert the medewerker token attaches to /behandel/* (and not to the anonymous openbaar call). The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy path.

Self-service withdrawal: "trek aanvraag in" (S-11c, #12)

The submit confirmation grows a "Trek aanvraag in" action so a zorgprofessional can withdraw the registration they just submitted (apps/self-service, on the existing RegistrationPage).

  • Keyed by the reference, owner-scoped at the BFF. The button calls the generated postSelfServiceRegistrationsIdWithdraw(reference) with the reference the submit returned. The DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
  • Same confirm-and-surface pattern as submit. A secondary-action button; on success the page switches to an ingetrokken confirmation; a failure is surfaced (role="alert") and the action stays available to retry — mirroring how submit handles its failure rather than swallowing it.
  • Testing. Component tests (@testing-library/angular, mocked BFF) cover the button appearing after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the browser round-trip is tests/e2e/withdrawal.spec.ts.