All checks were successful
## What & why Final sub-slice of **S-11 · Withdrawal (Flow 3)** — the user-facing "trek aanvraag in" action, which **closes #12**. - **self-service portal**: the submit confirmation gains a **"Trek aanvraag in"** button. It withdraws the just-submitted registration via `postSelfServiceRegistrationsIdWithdraw(reference)`; success shows an *ingetrokken* confirmation, a failure is surfaced (`role="alert"`) and the action stays available — same confirm-and-surface pattern as submit. - **acceptance**: `Een registratie intrekken` — owner withdraws → INGETROKKEN + workflow cancelled; a different bsn is reported not-found. - **e2e**: `withdrawal.spec.ts` — DigiD submit → trek aanvraag in → the portal confirms ingetrokken. - **docs**: demo-script + frontend-decisions. Together with S-11a (#88), S-11b (#89), S-11c-1 (#90), this completes the flow: citizen withdraws → domain INGETROKKEN → BPMN message event cancels the process → the case leaves the behandelaar's werkbak. Closes #12 ## Definition of Done - [x] Linked Gitea issue (#12). - [x] Failing tests committed before the implementation. - [x] Implementation makes the tests pass. - [x] Conventional Commits referencing the issue (`refs #12`). - [ ] CI green — all Gitea Actions jobs. - [x] `docker compose up` unaffected. - [x] Docs updated (demo-script + frontend-decisions). - [x] ADR — ADR-0014 (from S-11b) covers the cancellation decision; nothing new here. ## Notes for reviewers - Full local gate run before pushing: `dotnet format --verify-no-changes` clean; `make unit` green (Acceptance **11** incl. the 2 new withdrawal scenarios, Big 95, BFF 30, Acl 27, EventSubscriber 19); self-service lint/test/build green (9 tests, incl. the 2 new withdraw tests). - `withdrawal.spec.ts` waits on the *ingetrokken* confirmation (which only renders after the withdraw POST returns), so it can't cancel the request early (the 499 lesson from #87). Live-validated by verify-stack. Reviewed-on: #93
170 lines
12 KiB
Markdown
170 lines
12 KiB
Markdown
# 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 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, 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 nginx, 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 → nginx forwards to the
|
|
BFF: **no CORS**, and the DigiD token (same-origin) is attached by the interceptor. nginx resolves
|
|
the BFF at request time (a `resolver` + variable `proxy_pass`) so it starts before the BFF is up.
|
|
- **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.json` — `main.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 nginx, 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 nginx, 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`.
|