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.
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.yamlunderallowBuilds(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(thevitest-angularrunner). Angular Testing Library is added for component tests when the first real components land (S-08c); the S-08a placeholder uses a plainTestBedrender assertion. - Lint: ESLint (flat config,
@nx/eslint). - Nx is scoped to
apps/+libs/only. The@nx/dockerand@nx/dotnetplugins are not installed — the .NET services are built bydotnet/the Makefile, and@nx/dockerwould otherwise infer everyservices/*/Dockerfileas an unnamed Nx project and break the project graph. - No Nx Cloud.
nxCloudIdis stripped fromnx.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
frontendjob (make frontend→pnpm 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, unlikeopenapi-generator), so it runs in the pnpm/Node CI lane. It emits an injectableBffApiV1Serviceusing Angular'sHttpClient— which means the DigiD bearer token can be attached by anHttpInterceptor(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 intosrc/lib/generated/,clean: true, prettier). Regenerate withnx run api-client:generateafter the BFF spec changes; the output is deterministic (idempotent), andsrc/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 inapps/self-service/src/styles.css). Utrecht is NL DS's reference Angular implementation. Its v3 components are NgModule-based, not standalone, solibs/uire-exportsUtrechtComponentsModule(and the component classes, so the AOT compiler resolves the template directives through the barrel); standalone components consume it viaimports: [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 Keycloakdigidrealm (public clientbig-portal). A smallAuthServiceabstraction (bsn / isAuthenticated / login) wraps the library so components and theauthenticatedGuarddepend on a mockable surface; a tokenHttpInterceptorattaches the bearer to BFF calls (secure route). The OIDCauthority/secureApiOriginare dev defaults inapp.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) withAuthServiceand the api-client mocked; the axe (vitest-axe) check runs scoped to WCAG 2.1 AA tags (wcag2a/2aa/21a/21aa) with the documentlangset, 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-serviceimage serves the built app and reverse-proxies/self-service/*+/openbaar/*to thebffservice. 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 barebffname on every engine — compose, podman and Kubernetes (ADR-0034; theCaddyfilesits next to each app'sDockerfile). - Runtime config. The app fetches
/config.jsonbefore bootstrap (main.ts);appConfigis a factory. The dev default (public/config.json) points atlocalhost: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.shruns Playwright in a container oncg, so the browser reaches Keycloak askeycloak:8080— the same issuer the BFF validates against (resolves the browser-vs-container mismatch, ADR-0010). It uses the officialmcr.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 withtests/e2e/package.json's@playwright/testversion. The spec is copied in (docker cp), not mounted, so it leaves nothing root-owned on the host. Wired asverify-e2ein theverify-stackCI 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, soauthorize()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 fullchannel: 'chromium'build, not the default headless-shell). This emulates the production HTTPS secure context without touching the app or its production config. tests/e2eis a standalone Playwright project (its ownpackage.json), not an Nx project — it's a live-stack check like the otherverify-*runners, not part of thefrontendunit lane.
Openbaar Register portal (S-09, #10)
- Anonymous, no auth. The openbaar register is a public read, so
apps/openbaarhas noangular-auth-oidc-client, no interceptor, and noconfig.json—main.tsbootstrapsappConfigdirectly with justprovideHttpClient+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
openbaarimage serves the built app and reverse-proxies/openbaarto 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/naamnever leave the BFF. The e2e asserts the bsn never renders. - Loads on open, filters on search.
RegisterPagefetches 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 Keycloakmedewerkerrealm (public clientbig-portal), notdigid. Rather than fork the auth lib, the abstractAuthServicegrew aroles/hasRolesurface (empty for realms without roles, e.g.digid), and a parallelMedewerkerAuthService+provideMedewerkerAuthwere added — same auth-code + PKCE config, bound to the medewerker realm, reading the nestedrealm_access.rolesclaim. The library's ownauthInterceptorattaches 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-portalclient gets a realm-roles protocol mapper (realm_access.roles, added to id + userinfo tokens). The BFF remains the security boundary (behandelaarpolicy, 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
behandelimage serves the built app and reverse-proxies/behandelto 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.
WerkbakPageloadsGET /behandel/werkbakon open and renders a row per registration (referentie/bsn/status). Goedkeuren/afwijzenPOST /behandel/registrations/ {id}/decideand then reload the werkbak, so the handled item drops off (its FlowableBeoordelentask is completed). Per-row decide buttons carry anaria-labelincluding the reference, so the e2e (and screen readers) can target a specific registration in a shared werkbak. - Testing. Component tests use
@testing-library/angularwithBffApiV1Service/AuthServicemocked and the axe WCAG 2.1 AA check; anapp.config.specdrives 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 istests/e2e/withdrawal.spec.ts.