Compare commits

..
Author SHA1 Message Date
ehoandClaude Opus 5 334cec0157 docs(architecture): import the FDS architecture decisions (refs #159)
CI / lint (pull_request) Canceled after 0s
CI / build (pull_request) Canceled after 0s
CI / unit (pull_request) Canceled after 0s
CI / compose-smoke (pull_request) Canceled after 0s
Bring the engineer-facing FDS documentation next to the code it
describes: ADR-0001 to ADR-0006, the ADR index and template, the L3
component view, and the slice-1 proposal. All translated to Dutch.
Source: projects/open-register-fd/ in Respellion/innovation-lab.

Land the set in docs/architecture/fds/ rather than docs/architecture/.
This repo already owns adr-0001-loose-coupling to adr-0004-bdd-framework,
so a flat import collides on every number. The subfolder keeps the
imported numbering, and with it about thirty ADR-000N cross-references
in the imported text.

Add the mermaid custom fence to pymdownx.superfences. Without it the
imported diagrams publish as raw code blocks, because the site has no
mermaid support today. Add the nav group and one link from the docs
index.

The blueprint, the FDS gap analysis and the privacy views stay in the lab
repo; the OKRs cite them and they feed tender responses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:27:36 +02:00
421 changed files with 808 additions and 44924 deletions
-20
View File
@@ -1,20 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "4.15.0",
"commands": [
"dotnet-stryker"
],
"rollForward": false
},
"dotnet-ef": {
"version": "10.0.0",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
-17
View File
@@ -1,17 +0,0 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
# .NET sources use 4-space indent (dotnet format enforces this). The 2-space default
# above is for the frontend (TS/HTML/CSS/JSON); C# keeps the .NET convention.
[*.cs]
indent_size = 4
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+6 -252
View File
@@ -9,12 +9,6 @@ on:
permissions:
contents: read
# Supersede stale runs: a new push to the same branch/PR cancels the previous run, so the runner's
# concurrency slots aren't spent on commits nobody is waiting for (refs #127).
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
@@ -23,274 +17,34 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
runs-on: respellion-linux
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
# Cache the NuGet package store so each .NET job restores from disk, not the network. There are
# no lock files (so setup-dotnet's built-in cache doesn't apply); key on the project files. @v3
# avoids the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); cache is best-effort
# — a miss just restores from the network. See issue #73.
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make lint
build:
runs-on: ubuntu-latest
runs-on: respellion-linux
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make build
unit:
runs-on: ubuntu-latest
runs-on: respellion-linux
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make unit
# Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote.
- name: Unit test summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
frontend:
runs-on: ubuntu-latest
compose-smoke:
runs-on: respellion-linux
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
with:
version: 11
- uses: https://github.com/actions/setup-node@v4
with:
node-version: '24'
cache: 'pnpm'
- run: make frontend
# Job summary (#136): a per-frontend (app) pass/fail table from the vitest JSON each app wrote.
- name: Frontend test summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/vitest-summary.py test-output >> "$GITHUB_STEP_SUMMARY"
mutation:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make mutation
# Job summary (#136): render each service's Stryker Markdown report on the run page (Gitea
# 1.27 $GITHUB_STEP_SUMMARY). `if: always()` so a ratchet break still reports — and because
# `make mutation` stops at the first break, the summary also shows exactly where it stopped.
# Guarded so it no-ops on a runner/server without summary support. Strips the report's UTF-8 BOM.
- name: Mutation score summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
{
echo "## 🧬 Mutation testing"
echo
for svc in acl event-subscriber domain bff; do
echo "### $svc"
echo
report=$(ls services/"$svc"/StrykerOutput/*/reports/mutation-report.md 2>/dev/null | sort | tail -1)
if [ -n "$report" ]; then
sed '1s/^\xef\xbb\xbf//' "$report"
else
echo "_No report — \`make mutation\` stopped before \`$svc\` (earlier ratchet break)._"
fi
echo
done
} >> "$GITHUB_STEP_SUMMARY"
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
# ratchet fails — that is exactly when you want to inspect the survivors.
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
# ratchet (make mutation's exit code), not the report, so a Gitea artifact-backend
# 500 must not fail the job (gitea-actions-gotchas.md §4). Glob handles Stryker's
# non-deterministic StrykerOutput/<timestamp>/ dir. Pinned @v3: @v4's bundled
# @actions/artifact hard-aborts on non-github.com (GHES guard) — see the runbook.
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: acl-mutation-report
path: services/acl/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: event-subscriber-mutation-report
path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: domain-mutation-report
path: services/domain/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: bff-mutation-report
path: services/bff/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
# One stage for every check that needs the live stack. Booting OpenZaak once (instead
# of once per job) is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs
# in a built image and everything reaches services by container IP. Needs Docker + egress
# (base images, nuget, selectielijst.openzaak.nl).
#
# `needs: [mutation]` is NOT a data dependency — it serialises the two memory-heavy jobs so
# they never co-schedule now the runner has capacity >1. A concurrent Stryker run + full-stack
# bring-up + Playwright browser on one host is what OOMs the e2e (commit d5e5fa2, #126). The
# light .NET/frontend jobs have no `needs`, so they still parallelise up to runner capacity.
#
# No `if: ${{ !cancelled() }}` here (removed in #134): on Gitea 1.27 + act_runner 2.0.0, a job
# gated by a status-function `if` (always()/cancelled()) on top of `needs` routes through the new
# transitional "Cancelling" state + capability negotiation and never leaves `waiting` — it's never
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
# re-run exercises verify-stack, so we still get the signal.
verify-stack:
needs: [mutation]
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
# Bring the full stack up + wait for health — this also is the DoD "compose up
# reaches green health" smoke (it replaces the old compose-smoke job).
# Each check carries an `id` so the summary step below can report its per-check outcome (#136).
# A failed check skips the rest (no step `if:`), so the table shows exactly where it stopped.
- name: Bring up the full stack & wait for health
id: up
run: make verify-up
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
id: obs
run: OBS_TIMEOUT=180 make verify-observability
- name: Objecttypen API up + token authenticates
id: objecttypen
run: OBJECTTYPEN_TIMEOUT=120 make verify-objecttypen
- name: Objecten API up + token authenticates + trusts Objecttypen
id: objecten
run: OBJECTEN_TIMEOUT=120 make verify-objecten
- name: RegisterRecord objecttype registered + published
id: registerrecord
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
- name: ACL ↔ OpenZaak integration tests
id: acl
run: make verify-acl
- name: OpenZaak → NRC notification delivery
id: nrc
run: make verify-nrc
- name: OpenZaak → NRC → Event Subscriber → projection-api
id: projection
run: make verify-projection
- name: Objecten → NRC notification delivery
id: objecten_nrc
run: make verify-objecten-notifications
- name: Domain → Flowable → ACL → OpenZaak
id: domain
run: make verify-domain
- name: BFF → Keycloak + domain + projection
id: bff
run: make verify-bff
- name: Distributed traces reach Tempo (one connected trace across services)
id: tracing
run: TRACING_TIMEOUT=120 make verify-tracing
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
id: metrics
run: METRICS_TIMEOUT=120 make verify-metrics
- name: Self-service e2e (Playwright, login → submit → success)
id: e2e
run: make verify-e2e
# Job summary (#136): a pass/fail table of every live-stack check, so a red verify-stack shows
# which check failed at a glance. `if: always()` (step-level — safe on runner 2.0.0, unlike the
# job-level status-function `if` of #134) so it renders even after a check fails.
- name: verify-stack check summary
if: always()
env:
UP: ${{ steps.up.outcome }}
OBS: ${{ steps.obs.outcome }}
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
OBJECTEN: ${{ steps.objecten.outcome }}
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
ACL: ${{ steps.acl.outcome }}
NRC: ${{ steps.nrc.outcome }}
PROJECTION: ${{ steps.projection.outcome }}
DOMAIN: ${{ steps.domain.outcome }}
BFF: ${{ steps.bff.outcome }}
TRACING: ${{ steps.tracing.outcome }}
METRICS: ${{ steps.metrics.outcome }}
E2E: ${{ steps.e2e.outcome }}
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
icon() { case "$1" in success) echo "✅";; failure) echo "❌";; skipped) echo "⏭️";; cancelled) echo "🚫";; *) echo "❔ ${1:-—}";; esac; }
{
echo "## 🔌 verify-stack checks"
echo
echo "| Check | Result |"
echo "| ----- | :----: |"
echo "| Bring up + health | $(icon "$UP") |"
echo "| Observability backplane | $(icon "$OBS") |"
echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
echo "| OpenZaak → NRC | $(icon "$NRC") |"
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
echo "| Domain → Flowable → ACL → OpenZaak | $(icon "$DOMAIN") |"
echo "| BFF → Keycloak + domain + projection | $(icon "$BFF") |"
echo "| Distributed traces (Tempo) | $(icon "$TRACING") |"
echo "| Golden-signal metrics (Prometheus) | $(icon "$METRICS") |"
echo "| Self-service e2e (Playwright) | $(icon "$E2E") |"
} >> "$GITHUB_STEP_SUMMARY"
# Job summary (#136): per-spec Playwright results, from the JSON report run-e2e-check.sh copied
# out of the e2e container. Turns a red e2e into a one-glance "which spec" instead of a log dive.
- name: e2e spec summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/playwright-summary.py tests/e2e/playwright-report.json >> "$GITHUB_STEP_SUMMARY"
# Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure
if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true
- name: Tear down
if: always()
run: make down
- run: make smoke
-29
View File
@@ -15,9 +15,6 @@ coverage*.json
coverage*.xml
*.coverage
# Stryker.NET mutation-testing reports (regenerated by `make mutation`)
StrykerOutput/
# Rider / VS / VS Code
.idea/
.vs/
@@ -35,29 +32,3 @@ site/
# OS
.DS_Store
Thumbs.db
# ── Frontend (Nx / Angular / pnpm) ──
node_modules/
dist/
tmp/
out-tsc/
/coverage
.angular/
.nx/cache
.nx/workspace-data
.nx/self-healing
.nx/migrate-runs
.nx/polygraph
vite.config.*.timestamp*
vitest.config.*.timestamp*
.angular
# Playwright e2e (installed/generated in-container or on local runs)
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
__pycache__/
TestResults/
test-output/
tests/e2e/playwright-report.json
-8
View File
@@ -1,8 +0,0 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
/.nx/workspace-data
.angular
.nx/self-healing
-3
View File
@@ -1,3 +0,0 @@
{
"singleQuote": true
}
+21 -83
View File
@@ -151,47 +151,32 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
### S-08 · Self-Service portal (Angular, NL DS) — submit a registration
> **S-08 was split** (CLAUDE.md §13; issue #9 closed) into the sub-slices below — it bundled the
> Nx bootstrap, the generated client, the NL DS + DigiD form, and a full-stack Playwright e2e, well
> past 12 days. Each sub-slice is independently demoable and CI-green.
- **S-08a (#65)** · Nx monorepo + Angular tooling + CI Node lane. Placeholder `self-service` app; `nx lint/test/build` green in a new CI Node lane.
- **S-08b (#66)** · Generated api-client lib from `services/bff/openapi.json` (never hand-written, §10) + a mocked-BFF unit test.
- **S-08c (#67)** · Self-service submit form — NL Design System `libs/ui`, DigiD OIDC `libs/auth`, component tests (Angular Testing Library), axe WCAG 2.1 AA on the submit page.
- **S-08d (#68)** · Playwright happy-path e2e (login → submit → success) against the full stack + compose serving + CI e2e lane.
**Out of scope (whole of S-08):** document upload, status tracking page.
### S-09 · Openbaar Register portal — public lookup *(#10)*
**Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Shows the public-visibility half of the walking skeleton.
_Split from the original S-09 — scoped to the portal only; the approval flow is **S-09b (#75)**._
**Outcome:** The self-service Angular app, in the Nx monorepo, lets a zorgprofessional log in via mock DigiD and submit a registration. NL Design System styling. Generated API client.
**Acceptance:**
- E2E test: after a zorgprofessional registers via self-service (S-08), the openbaar register shows the entry (as `INGEDIEND`).
- Public-safe field whitelist enforced and tested (already in the BFF; add a portal component test + a11y check).
- E2E test (Playwright): full happy path, login → submit → success page.
- Component tests (Testing Library) for the form.
- Accessibility audit (axe-core) passes WCAG 2.1 AA on the submit page.
**Touches:** `apps/openbaar/`, compose serving, e2e, docs.
**Touches:** `apps/self-service/`, `libs/ui/`, `libs/auth/`, `libs/api-client/`, tests.
**Out of scope:** approval/status transition (S-09b), advanced search filters, sorting.
**Out of scope:** document upload, status tracking page.
### S-09b · Approval flow — temp admin endpoint + status transition to projection *(#75)*
### S-09 · Openbaar Register portal — public lookup
**Outcome:** A behandelaar approves a submitted registration via a temporary admin endpoint (no behandel-portal yet — S-12). The approval transitions the zaak status through the ACL → NRC → event-subscriber → projection, and the openbaar register then shows the entry as approved.
**Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Confirms the walking skeleton end-to-end.
**Acceptance:**
- A new terminal/approved status (e.g. `INGESCHREVEN`) exists and is projected.
- Temporary admin approve endpoint transitions a registration via a real ZGW status set (behind the ACL, §8).
- E2E: register (S-08) → approve → openbaar shows the entry as approved.
- E2E test: zorgprofessional registers via self-service (S-08), behandelaar approves via a temporary admin endpoint (no behandel-portal yet), openbaar register shows the entry.
- Public-safe field whitelist enforced and tested.
**Touches:** `services/domain`, `services/acl`, `services/event-subscriber`, `services/projection-api`, e2e.
**Touches:** `apps/openbaar/`, projection-api hardening, tests.
**Out of scope:** behandel-portal UI (S-12), assessment logic (S-13), escalation (S-15).
**Out of scope:** advanced search filters, sorting.
**End of walking skeleton** (S-09 + S-09b). Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases.
**End of walking skeleton.** Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases.
---
@@ -199,25 +184,9 @@ _Split from the original S-09 — scoped to the portal only; the approval flow i
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary:
**Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled.
#### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
#### S-10b · Real diploma upload stored via the ACL Documenten API — #103
**Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the file (base64-encoded end-to-end) is stored in the ZGW Documenten (DRC) API as an `enkelvoudiginformatieobject` and related to the zaak, with all document calls routed through the ACL (§8.1, ADR-0018). Builds on the S-10a trigger/wait. Depends on #102.
**Acceptance:** ACL Documenten gateway integration test (real OpenZaak); Playwright e2e uploads a real PDF.
#### S-10c · Close the ZGW zaak on document-timeout expiry — #106
**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a distinct non-terminal `Geannuleerd` status + `Vervallen` resultaat (not just the domain aggregate → `Verlopen`), resolved by name in the ACL. Adds the cancellation statustype/resultaattype to the seed + an ACL `CancelZaakAsync`/`POST /annuleringen` + expiry-worker wiring. Carved from S-10b (ADR-0017/0018/0019). Depends on #103.
**Acceptance:** ACL↔OpenZaak integration test (cancellation records `Geannuleerd` + a resultaat, live); the domain verify script fires the P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD asserts the zaak is cancelled on timeout but untouched when documents arrive in time.
**Acceptance:** BDD scenarios for both branches; integration tests for the timer firing.
### S-11 · Withdrawal (Flow 3)
@@ -239,67 +208,36 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
### S-26 · Self-service — resume an existing registration after refresh — #111
**Outcome:** a signed-in zorgprofessional who reloads the self-service portal (or returns later) gets back to their in-flight registration and its actions (Documenten aanleveren, Trek aanvraag in), instead of a blank submit form with the reference lost. Today all post-submit state lives in in-memory signals, the reference is not in the URL, and there is no self-service read endpoint — so a reload strands the registration. Adds an owner-scoped (DigiD bsn) `GET /self-service/registrations` on the BFF/domain and a load-on-init/route restore in the portal.
**Acceptance:** BDD — resume after refresh shows the existing registration; lookup is owner-scoped (never another citizen's); a user with no in-flight registration still sees the submit form. Playwright e2e reloads mid-flow and asserts the actions remain reachable.
---
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
### S-15 · Beheer-portal — catalogus & default-fill rules *(split — #16 closed)*
### S-15 · Beheer-portal — catalogus & default-fill rules
**Outcome:** Beheer portal lets an admin view ZTC catalogi (read-only first), and manage the ACL's default-fill configuration via a CRUD UI. MFA on the medewerker realm enforced.
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-15a** (#130) · Beheer portal skeleton + read-only catalogi viewer — new beheer Angular app (medewerker-realm login) showing ZTC catalogi/zaaktypen read-only, via a BFF `/beheer/*` read endpoint proxying a read-only ACL Catalogi endpoint (§8.1, reuses the ADR-0021 Catalogi client).
- **S-15b** (#131) · ACL default-fill configuration CRUD — the `Acl__Defaults__*` config (ADR-0003) becomes a managed store with CRUD via the BFF + a portal UI. Depends on S-15a.
- **S-15c** (#132) · Enforce MFA (OTP) on the Keycloak medewerker realm.
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
### S-16 · OpenTelemetry traces + Grafana dashboard
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
Split into independently deployable sub-slices (CLAUDE.md §13):
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep
- **S-16a** (#122) · Observability backplane — Grafana Tempo + Prometheus + Grafana in compose, datasources auto-provisioned (ADR-0023). No collector; config baked into built images.
- **S-16b** (#123) · Distributed traces across the five .NET services (OTLP → Tempo; traceparent propagates via the typed HttpClients). Depends on S-16a. ✅
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a. ✅
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅
**Outcome:** Daily Quartz.NET cron job finds inscriptions within 90 days of their herregistratie deadline and reminds each (flag on the aggregate + log). No outbound notification and no domain event in v1 — the reminder is the persisted flag, surfaced on the read model (ADR-0022, #120). Quartz fires time-triggered sweeps; the existing pumps stay as queue-drainers.
**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
---
## Iteration 4 — Objecten and the authoritative register *(milestone: `Iteration 4 — Objecten`)*
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined *(split — #19 closed)*
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined
**Outcome:** Objecten and Objecttypen running. A `RegisterRecord` objecttype defined with the public-safe schema.
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-18a** (#139, ✅) · Objecttypen API up in compose (own DB + seeded config + health + static token).
- **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a.
- **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)*
### S-19 · ACL extension: write register-record to Objecten on approval
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
**ADR required:** "Why Objecten holds the register, OpenZaak holds the process."
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
- **S-19b** (#150, ✅) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)*
- **S-19b-1** (#152, ✅) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled.
- **S-19b-2** (#153, ✅) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. The ACL also writes an INGEDIEND record on submit, so the register holds the whole lifecycle. Carries ADR-0030.
---
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
+1 -112
View File
@@ -2,130 +2,19 @@
All notable changes to this project. Generated from Conventional Commits by git-cliff.
## v2026.07.0 — 2026-07-14
### Architecture
- ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
- ADR-0006 — provision the ACL integration test against the compose stack (refs #46)
- ADR-0007 + runbooks for the OZ→NRC notification wiring (refs #56)
- ADR-0009 external-task job-worker pattern (refs #6, #60)
- ADR-0010 BFF OIDC validation + downstream boundaries (refs #8, #63)
### Bug Fixes
- Pin OpenZaak/NRC image tags; add smoke log capture on failure (refs #30)
- Harden oz-db healthcheck and raise compose-up timeout (refs #30)
- Bake config into images so compose-smoke passes on CI (refs #30)
- Nrc-init runs migrations only, not setup_configuration (refs #30)
- Smoke waits on durable services, not the whole project (refs #30)
- Portable health poll instead of compose --wait (refs #30)
- Pin upload-artifact to @v3@v4 refuses to run on Gitea (refs #47)
- Buffer the zaak POST body so OpenZaak accepts it (refs #46)
- Keep dotnet format green under the shared .editorconfig (refs #65)
- Re-export the full Utrecht package from libs/ui (refs #67)
- Run checkAuth() at startup to end the login redirect loop (refs #67)
- Health-check nginx over IPv4 (127.0.0.1) (refs #68)
- Treat the http portal origin as secure so DigiD PKCE login works (refs #68)
- Attach the DigiD token to relative BFF calls (refs #68)
### Build
- Pin Stryker.NET as a local dotnet tool (refs #47)
## Unreleased
### CI
- Gitea Actions pipeline + runner runbook (refs #30) (#37)
- ACL Dockerfile + full compose stack for smoke test (refs #30)
- Switch runner label to ubuntu-latest (refs #30)
- Run the mutation ratchet as a parallel CI job (refs #47)
- Publish the Stryker HTML report as a CI artifact (refs #47)
- Run the ACL integration test as a Gitea Actions job (refs #46)
- Keep the integration lane local-only; document the runner gap (refs #46)
- Run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
- Run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
- Containerize, wire into compose, and verify end-to-end (refs #6)
- Make Stryker report upload best-effort (refs #62)
- Retrigger after runner cleanup (refs #6)
- Retrigger CI (refs #6)
- Retrigger CI after gitea restart (refs #6)
- Compose wiring, verify-bff live check, mutation baseline (refs #8)
- Nx frontend lane (lint/test/build) (refs #65)
- Serve the self-service app in compose (refs #68)
- Run Vitest ahead of the production build to stop worker-start timeout (refs #68)
- Cache the NuGet package store across the .NET jobs (refs #73)
- Run Playwright from the prebuilt image instead of downloading browsers (refs #73)
### Chores
- Add idempotent Gitea backlog seeder
- Remove bootstrap scripts from main (#35)
- Contributor workflow — templates, git-cliff, gitea-workflow doc (closes #31) (#38)
### Documentation
- Split S-00 into sub-slices (refs #1) (#33)
- MkDocs scaffold + ADR-0001 + README quickstart (closes #32) (#39)
- Tighten gitea-actions-gotchas, add local compose (refs #30)
- ADR-0008 read projection store + demo note for the event path (refs #7)
- Demo note for submitting a registration (S-05) (refs #6)
- Demo note for the BFF front door (S-07) (refs #8)
- Split S-08 into S-08a-d (refs #65)
- Frontend-decisions + demo note for S-08a (refs #65)
- Record the orval generator choice (refs #66)
- Record NL DS + DigiD decisions and demo note (refs #67)
- Serving/e2e decisions + walking-skeleton demo note (refs #68)
### Features
- Placeholder BFF + /health endpoint (closes #28) (#34)
- Containerize BFF + compose-up smoke (closes #29) (#36)
- OpenZaak + Postgres + Redis up in compose (refs #10) (#40)
- Seed BIG catalogus + JWT client for OpenZaak (refs #2) (#41)
- Open Notificaties up + shared network (closes #2) (#42)
- Keycloak with four mock realms (closes #3) (#43)
- Flowable + registratie.bpmn external task (closes #4) (#44)
- ACL skeleton — OpenZaak default-fill (refs #5) (#45)
- Add bind-mount local compose for no-make/Windows dev (refs #30)
- Publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
- Wire OpenZaak → Open Notificaties notifications (refs #56)
- Project zaak-created notifications into the read projection (refs #7)
- Persist the read projection and expose webhook + read APIs (refs #7)
- Enforce the callback bearer before reading the body (refs #7)
- Implement the Registration aggregate invariants (refs #6)
- Implement SubmitRegistration and OpenZaakWorker (refs #6)
- Implement the Flowable Workflow Client and ACL client (refs #6)
- Expose POST /registrations and the read endpoint (refs #6)
- Implement self-service submit and openbaar lookup (refs #8)
- Committed OpenAPI contract + drift guard (refs #8)
- Self-service portal placeholder page (refs #65)
- Expose the generated BFF client + repeatable generate target (refs #66)
- Implement the DigiD registration submit page (refs #67)
- Runtime config + nginx serve/proxy image (refs #68)
- Surface submit failures with a retryable alert (refs #68)
- One citizen reference across self-service and the openbaar register (#79)
### Other
- Openbaar Register portal — public lookup (#76)
- Approval flow — temp admin endpoint + status transition to projection (#77)
### Refactor
- Bake config via dockerfile_inline, drop Dockerfile files (refs #30)
- Use upstream images verbatim, seed config via docker cp (refs #30)
- One verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
### Tests
- BDD acceptance scenario for opening a zaak (closes #5) (#49)
- Kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
- Add Stryker config + mutation make target recording the 95% baseline (refs #47)
- Integration test opens a real zaak against OpenZaak (refs #46)
- Verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
- Project zaak-created notifications into the read projection (refs #7)
- Ratchet projector mutation baseline to 100% (refs #7)
- Registration aggregate invariants (refs #6)
- SubmitRegistration + OpenZaakWorker use cases (refs #6)
- Workflow Client, ACL client, store and job processor (refs #6)
- Acceptance scenario for submitting a registration (refs #6)
- Mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
- Endpoints, JWT auth and public-safe projection (refs #8)
- Acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
- Self-service portal placeholder renders (refs #65)
- Generated BFF client is exposed and calls the endpoints (refs #66)
- DigiD-guarded registration submit page (refs #67)
- Walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
- Submit surfaces BFF failures instead of swallowing them (refs #68)
- Guard that the DigiD token attaches to relative BFF calls (refs #68)
+14 -199
View File
@@ -7,22 +7,7 @@
SLN := register-referentie.slnx
COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the
# containerized CI runner. SEED populates them; run it before every `up`. The
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
SEED := bash infra/seed-config.sh
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn rr-objecttypen-config rr-objecten-config rr-registerrecord-config
# Local-only stack: same services but config is bind-mounted (no seed step), so a
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
# engine. This is the no-make / Windows-friendly path. See that file's header.
LOCAL_COMPOSE := infra/docker-compose.local.yml
HEALTH_URL := http://localhost:8080/health
OZ_COMPOSE := infra/openzaak/docker-compose.yml
OZ_BASE := http://localhost:8000
NRC_COMPOSE := infra/opennotificaties/docker-compose.yml
@@ -43,23 +28,10 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
.PHONY: ci lint build unit smoke down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
ci: lint build unit mutation frontend verify
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
# Tests run in their own phase, ahead of the build. The @angular/build:unit-test
# (Vitest) runner spawns a worker with a hard-coded 60s/90s startup timeout that is
# not configurable. When the ~5min production build shares the run-many pool, it
# starves that worker of CPU on constrained CI runners and Vitest fails with
# "Timeout waiting for worker to respond". Splitting the phases keeps tests off the
# heavy build's back so the worker starts well inside its window.
frontend:
pnpm install --frozen-lockfile
pnpm nx run-many -t lint test
pnpm nx run-many -t build
## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions)
ci: lint build unit smoke
## lint: verify formatting (no changes)
lint:
@@ -69,174 +41,30 @@ lint:
build:
dotnet build $(SLN) -c Release
## unit: run unit tests (excludes the container-backed Integration lane)
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
## unit: run unit tests
unit:
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
dotnet test $(SLN) -c Release
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
# makes `make mutation` work from a fresh clone. Each service owns its config + break
# threshold (the ratchet, CLAUDE.md §5): each services/<svc>/stryker-config.json.
# Scores never regress below baseline.
mutation:
dotnet tool restore
cd services/acl && dotnet stryker
cd services/event-subscriber && dotnet stryker
cd services/domain && dotnet stryker
cd services/bff && dotnet stryker
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
# SEED populates the external config volumes first (upstream images used verbatim;
# only our acl/bff are built). `up -d --build` starts EVERYTHING. Readiness is
# checked by infra/wait-healthy.sh polling the durable, health-checked services
# ($(WAIT_SVCS)) via `docker inspect` — portable across docker compose and
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
# (oz-init, flowable-init) aren't polled; they just need to have run.
## smoke: compose up (wait for healthy), curl /health, then tear down
smoke:
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
docker compose -f $(COMPOSE) up -d --build --wait
bash -c 'curl -fsS $(HEALTH_URL); rc=$$?; docker compose -f $(COMPOSE) down --volumes; exit $$rc'
## up: seed config volumes and start the full stack (use instead of bare
## `docker compose up`, which can't self-seed the external config volumes)
up:
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
## down: stop and remove the local stack (incl. the external config volumes)
## down: stop and remove the local stack
down:
docker compose -f $(COMPOSE) down --volumes
-docker volume rm -f $(CFG_VOLS)
## local: bring up the bind-mount stack (no seed step) and wait for health
## (Windows / no-make users: run `docker compose -f infra/docker-compose.local.yml up -d --build` directly)
local:
docker compose -f $(LOCAL_COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
## verify-local: acceptance check for the local stack (S-B04) — a fresh `make local` completes the
## whole flow (zaaktype seeded + DMN deployed + NRC abonnement) with NO manual seeding.
verify-local:
bash infra/run-local-flow-check.sh
## local-down: stop and remove the bind-mount stack
local-down:
docker compose -f $(LOCAL_COMPOSE) down --volumes
## changelog: regenerate CHANGELOG.md from Conventional Commits (git-cliff)
changelog:
git-cliff --output CHANGELOG.md
# ── ZGW verification ───────────────────────────────────────────────────────
# On the single runner CI jobs run sequentially, so the OpenZaak-dependent checks
# share ONE full-stack bring-up: the `verify-stack` CI job runs `verify-up` then
# `verify-acl` + `verify-nrc` as steps against the same stack (issue #58). The
# check logic lives in stack-agnostic runners that reach services by container IP
# (gitea-actions-gotchas.md §5/§6); `integration` / `verify-notifications` are local
# convenience wrappers that bring up a lighter stack and call the same runners.
## verify-up: bring the FULL stack up and wait for health (CI verify-stack step 1;
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
verify-up:
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
## verify-acl: ACL ↔ OpenZaak integration tests against the already-running stack.
verify-acl:
bash infra/run-acl-integration.sh
## verify-nrc: OpenZaak → NRC notification delivery against the already-running stack.
verify-nrc:
bash infra/run-notification-check.sh
## verify-projection: OpenZaak → NRC → Event Subscriber → projection-api end-to-end (S-06),
## against the already-running stack.
verify-projection:
bash infra/run-projection-check.sh
## verify-domain: domain → Flowable → ACL → OpenZaak end-to-end (S-05), against the
## already-running stack. Recreates the acl service to inject the seeded zaaktype URL.
verify-domain:
bash infra/run-domain-check.sh
## verify-bff: BFF end-to-end (S-07) against the up stack — token validation on self-service
## + anonymous public-safe openbaar register (ADR-0010).
verify-bff:
bash infra/run-bff-check.sh
## verify-e2e: walking-skeleton Playwright e2e (S-08d) against the up stack — DigiD login →
## submit → confirmation, driven inside the compose network.
verify-e2e:
bash infra/run-e2e-check.sh
## verify-observability: assert the observability backplane (Grafana + provisioned Tempo &
## Prometheus datasources) is live, against the already-running stack (S-16a).
verify-observability:
bash infra/run-observability-check.sh
## verify-tracing: assert one connected distributed trace spans the .NET services in Tempo
## (S-16b), against the already-running stack.
verify-tracing:
bash infra/run-tracing-check.sh
## verify-metrics: assert the services expose /metrics and Prometheus scrapes the golden
## signals (S-16c), against the already-running stack.
verify-metrics:
bash infra/run-metrics-check.sh
## verify-objecttypen: assert the Objecttypen API is up + its static token authenticates
## (S-18a), against the already-running stack.
verify-objecttypen:
bash infra/run-objecttypen-check.sh
## verify-objecten: assert the Objecten API is up + its static token authenticates and it
## trusts the Objecttypen API (S-18b), against the already-running stack.
verify-objecten:
bash infra/run-objecten-check.sh
## verify-registerrecord: assert the RegisterRecord objecttype is registered + published in the
## Objecttypen API (S-18c), against the already-running stack.
verify-registerrecord:
bash infra/run-registerrecord-check.sh
## verify-objecten-notifications: assert a RegisterRecord write in Objecten is DELIVERED as an
## `objecten` notification via NRC (S-19b-1), against the already-running stack.
verify-objecten-notifications:
bash infra/run-objecten-notifications-check.sh
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead.
verify:
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
@bash -c 'set -e; rc=0; \
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
&& bash infra/run-acl-integration.sh \
&& bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \
&& bash infra/run-objecten-notifications-check.sh \
&& bash infra/run-domain-check.sh \
&& bash infra/run-bff-check.sh \
&& bash infra/run-e2e-check.sh || rc=$$?; \
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
exit $$rc'
## integration: local convenience — ACL integration test against a throwaway
## OpenZaak-only stack (fast iteration). CI uses verify-acl on the shared stack.
integration:
bash infra/run-integration.sh
## openzaak-up: start the OpenZaak stack (migrations run on first start)
openzaak-up:
$(SEED) oz
docker compose -f $(OZ_COMPOSE) up -d
## openzaak-smoke: start OpenZaak, then assert it is up with auth enforced
openzaak-smoke: openzaak-up
openzaak-smoke:
docker compose -f $(OZ_COMPOSE) up -d
@bash -c 'set -e; \
echo "waiting for OpenZaak to respond..."; \
for i in $$(seq 1 60); do \
@@ -260,18 +88,10 @@ openzaak-seed: openzaak-up
## openzaak-down: stop and remove the OpenZaak stack (wipes data)
openzaak-down:
docker compose -f $(OZ_COMPOSE) down --volumes
-docker volume rm -f rr-oz-config
## verify-notifications: local convenience — OpenZaak → NRC notification delivery
## against a throwaway oz+nrc stack (S-01-c). CI uses verify-nrc on the shared stack.
verify-notifications:
bash infra/verify-notifications.sh
## stack-up: start OpenZaak + Open Notificaties together (shared network), with
## OpenZaak publishing notifications to NRC (S-01-c).
## stack-up: start OpenZaak + Open Notificaties together (shared network)
stack-up:
$(SEED) oz nrc
OZ_NOTIFICATIONS_DISABLED=false docker compose $(STACK_FILES) up -d
docker compose $(STACK_FILES) up -d
## stack-smoke: start both, assert OpenZaak (403/302/200) and NRC (302) are reachable
stack-smoke: stack-up
@@ -290,11 +110,9 @@ stack-smoke: stack-up
## stack-down: stop and remove both stacks (wipes data)
stack-down:
docker compose $(STACK_FILES) down --volumes
-docker volume rm -f rr-oz-config rr-nrc-config
## keycloak-up: start Keycloak with the four imported realms
keycloak-up:
$(SEED) kc
docker compose -f $(KC_COMPOSE) up -d
## keycloak-smoke: start Keycloak, then verify each realm logs in + returns its claim
@@ -307,11 +125,9 @@ keycloak-smoke: keycloak-up
## keycloak-down: stop and remove Keycloak
keycloak-down:
docker compose -f $(KC_COMPOSE) down --volumes
-docker volume rm -f rr-kc-realms
## flowable-up: start Flowable (deploys registratie.bpmn on boot)
flowable-up:
$(SEED) fl
docker compose -f $(FL_COMPOSE) up -d
## flowable-smoke: start Flowable, then verify a started instance waits on the external task
@@ -324,7 +140,6 @@ flowable-smoke: flowable-up
## flowable-down: stop and remove Flowable
flowable-down:
docker compose -f $(FL_COMPOSE) down --volumes
-docker volume rm -f rr-fl-bpmn
## help: list available targets
help:
-27
View File
@@ -1,27 +0,0 @@
# Multi-stage build for the behandel portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/behandel apps/behandel
COPY libs libs
RUN pnpm nx build behandel
FROM nginx:1.27-alpine AS runtime
COPY apps/behandel/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
-34
View File
@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
-24
View File
@@ -1,24 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the behandel endpoint group to the bff service. The api-client uses
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
location /behandel/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
-82
View File
@@ -1,82 +0,0 @@
{
"name": "behandel",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/behandel/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/behandel",
"browser": "apps/behandel/src/main.ts",
"tsConfig": "apps/behandel/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/behandel/public"
}
],
"styles": ["apps/behandel/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "behandel:build:production"
},
"development": {
"buildTarget": "behandel:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "behandel:build",
"staticFilePath": "dist/apps/behandel/browser",
"spa": true
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-73
View File
@@ -1,73 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BffApiV1Service } from 'api-client';
import { authInterceptor } from 'auth';
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
import { SECURE_API_ROUTES } from './app.config';
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
// the behandel calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
// source and token storage are faked, so the assertion turns on the actual route-matching.
describe('behandel medewerker token wiring', () => {
let http: HttpTestingController;
let bff: BffApiV1Service;
const token = 'medewerker-access-token';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
{
provide: ConfigurationService,
useValue: {
hasAtLeastOneConfig: () => true,
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
},
},
{
// A signed-in session: the storage the interceptor's token lookup reads from.
provide: AbstractSecurityStorage,
useValue: {
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
write: () => undefined,
remove: () => undefined,
clear: () => undefined,
},
},
],
});
http = TestBed.inject(HttpTestingController);
bff = TestBed.inject(BffApiV1Service);
});
afterEach(() => http.verify());
it('attaches the bearer token to the relative werkbak call', () => {
bff.getBehandelWerkbak().subscribe();
const req = http.expectOne('/behandel/werkbak');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush([]);
});
it('attaches the bearer token to the relative decide call', () => {
bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe();
const req = http.expectOne('/behandel/registrations/reg-1/decide');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush(null);
});
it('leaves the anonymous openbaar register call unauthenticated', () => {
bff.getOpenbaarRegister().subscribe();
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
});
-39
View File
@@ -1,39 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideMedewerkerAuth } from 'auth';
import { appRoutes } from './app.routes';
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
authority: string;
}
/**
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/behandel/` is secured; the app calls no other endpoint group.
*/
export const SECURE_API_ROUTES = ['/behandel/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideMedewerkerAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}
View File
-1
View File
@@ -1 +0,0 @@
<router-outlet></router-outlet>
-7
View File
@@ -1,7 +0,0 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { WerkbakPage } from './werkbak/werkbak-page';
export const appRoutes: Route[] = [
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
];
-15
View File
@@ -1,15 +0,0 @@
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the WerkbakPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});
-12
View File
@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'behandel';
}
@@ -1,64 +0,0 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Werkbak</utrecht-heading-1>
<p utrecht-paragraph>
Registraties die wachten op beoordeling. Keur elke registratie goed of wijs deze af.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de werkbak niet laden. Controleer of je als behandelaar bent ingelogd en probeer het
opnieuw.
</p>
} @else if (loaded() && items().length === 0) {
<p utrecht-paragraph role="status">De werkbak is leeg.</p>
} @else if (items().length > 0) {
<table utrecht-table>
<caption>
Registraties in behandeling
</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">BSN</th>
<th scope="col">Status</th>
<th scope="col">Actie</th>
</tr>
</thead>
<tbody>
@for (item of items(); track item.registrationId) {
<tr>
<td>{{ item.registrationId }}</td>
<td>{{ item.bsn }}</td>
<td>{{ item.status }}</td>
<td>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[attr.aria-label]="'Goedkeuren ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'goedkeuren')"
>
Goedkeuren
</button>
<button
utrecht-button
appearance="secondary-action-button"
type="button"
[attr.aria-label]="'Afwijzen ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'afwijzen')"
>
Afwijzen
</button>
</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>
@@ -1,110 +0,0 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { WerkbakPage } from './werkbak-page';
const sample: WerkbakItem[] = [
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
{ registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' },
];
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['behandelaar']);
login(): void {
/* not exercised here */
}
logout(): void {
/* spied in tests */
}
}
function setup(
overrides: {
getBehandelWerkbak?: ReturnType<typeof vi.fn>;
postBehandelRegistrationsIdDecide?: ReturnType<typeof vi.fn>;
} = {},
) {
const getBehandelWerkbak =
overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample));
const postBehandelRegistrationsIdDecide =
overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined));
return {
getBehandelWerkbak,
postBehandelRegistrationsIdDecide,
providers: [
{
provide: BffApiV1Service,
useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide },
},
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('WerkbakPage', () => {
it('lists the registrations awaiting beoordeling on open', async () => {
const { getBehandelWerkbak, providers } = setup();
await render(WerkbakPage, { providers });
expect(getBehandelWerkbak).toHaveBeenCalled();
expect(await screen.findByText('reg-1')).toBeTruthy();
expect(screen.getByText('123456782')).toBeTruthy();
expect(screen.getByText('reg-2')).toBeTruthy();
});
it('approves a registration (goedkeuren) and refreshes the werkbak', async () => {
const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'goedkeuren',
});
// Reloaded after the decision: once on open, once after deciding.
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
});
it('rejects a registration (afwijzen) via the decide endpoint', async () => {
const { postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'afwijzen',
});
});
it('shows an empty state when the werkbak has no items', async () => {
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
await render(WerkbakPage, { providers });
expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy();
});
it('surfaces a load failure instead of swallowing it', async () => {
const { providers } = setup({
getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(WerkbakPage, { providers });
expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(WerkbakPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -1,65 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
type Besluit = 'goedkeuren' | 'afwijzen';
/**
* The behandel werkbak: a signed-in behandelaar sees the registrations awaiting beoordeling (the open
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
* decision posts to the BFF, which applies the domain transition and completes the workflow task
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
*/
@Component({
selector: 'app-werkbak-page',
imports: [UtrechtComponentsModule],
templateUrl: './werkbak-page.html',
})
export class WerkbakPage {
private readonly bff = inject(BffApiV1Service);
protected readonly items = signal<WerkbakItem[]>([]);
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly failed = signal(false);
protected readonly deciding = signal<string | undefined>(undefined);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.bff.getBehandelWerkbak().subscribe({
next: (rows: WerkbakItem[]) => {
this.items.set(rows);
this.loading.set(false);
this.loaded.set(true);
},
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
error: () => {
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
decide(registrationId: string, besluit: Besluit): void {
this.deciding.set(registrationId);
this.bff.postBehandelRegistrationsIdDecide(registrationId, { besluit }).subscribe({
// Refresh so the decided registration drops off the werkbak (its task is now completed).
next: () => {
this.deciding.set(undefined);
this.load();
},
error: () => {
this.deciding.set(undefined);
this.failed.set(true);
},
});
}
}
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Behandelportaal BIG-register</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
-10
View File
@@ -1,10 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
// Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));
-2
View File
@@ -1,2 +0,0 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
-31
View File
@@ -1,31 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
-27
View File
@@ -1,27 +0,0 @@
# Multi-stage build for the beheer portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/beheer apps/beheer
COPY libs libs
RUN pnpm nx build beheer
FROM nginx:1.27-alpine AS runtime
COPY apps/beheer/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/beheer/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
-34
View File
@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
-24
View File
@@ -1,24 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the beheer endpoint group to the bff service. The api-client uses
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
location /beheer/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
-82
View File
@@ -1,82 +0,0 @@
{
"name": "beheer",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/beheer/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/beheer",
"browser": "apps/beheer/src/main.ts",
"tsConfig": "apps/beheer/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/beheer/public"
}
],
"styles": ["apps/beheer/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "beheer:build:production"
},
"development": {
"buildTarget": "beheer:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "beheer:build",
"staticFilePath": "dist/apps/beheer/browser",
"spa": true
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-65
View File
@@ -1,65 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BffApiV1Service } from 'api-client';
import { authInterceptor } from 'auth';
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
import { SECURE_API_ROUTES } from './app.config';
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
// the beheer calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
// source and token storage are faked, so the assertion turns on the actual route-matching.
describe('beheer medewerker token wiring', () => {
let http: HttpTestingController;
let bff: BffApiV1Service;
const token = 'medewerker-access-token';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
{
provide: ConfigurationService,
useValue: {
hasAtLeastOneConfig: () => true,
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
},
},
{
// A signed-in session: the storage the interceptor's token lookup reads from.
provide: AbstractSecurityStorage,
useValue: {
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
write: () => undefined,
remove: () => undefined,
clear: () => undefined,
},
},
],
});
http = TestBed.inject(HttpTestingController);
bff = TestBed.inject(BffApiV1Service);
});
afterEach(() => http.verify());
it('attaches the bearer token to the relative catalogus call', () => {
bff.getBeheerCatalogiZaaktypen().subscribe();
const req = http.expectOne('/beheer/catalogi/zaaktypen');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush([]);
});
it('leaves the anonymous openbaar register call unauthenticated', () => {
bff.getOpenbaarRegister().subscribe();
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
});
-39
View File
@@ -1,39 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideMedewerkerAuth } from 'auth';
import { appRoutes } from './app.routes';
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
authority: string;
}
/**
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/beheer/` is secured; the app calls no other endpoint group.
*/
export const SECURE_API_ROUTES = ['/beheer/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideMedewerkerAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}
View File
-5
View File
@@ -1,5 +0,0 @@
<nav aria-label="Beheer" class="utrecht-theme">
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Catalogus</a>
<a routerLink="/default-fill" routerLinkActive="active">Default-fill</a>
</nav>
<router-outlet></router-outlet>
-9
View File
@@ -1,9 +0,0 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { CatalogusPage } from './catalogus/catalogus-page';
import { DefaultFillPage } from './default-fill/default-fill-page';
export const appRoutes: Route[] = [
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
{ path: 'default-fill', component: DefaultFillPage, canActivate: [authenticatedGuard] },
];
-15
View File
@@ -1,15 +0,0 @@
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the CatalogusPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});
-12
View File
@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'beheer';
}
@@ -1,40 +0,0 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Catalogus</utrecht-heading-1>
<p utrecht-paragraph>
De gepubliceerde zaaktypen uit de ZTC-catalogus. Alleen-lezen — beheer van de default-fill volgt
in een latere slice.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de catalogus niet laden. Controleer of je als beheerder bent ingelogd en probeer het
opnieuw.
</p>
} @else if (loaded() && items().length === 0) {
<p utrecht-paragraph role="status">De catalogus bevat geen gepubliceerde zaaktypen.</p>
} @else if (items().length > 0) {
<table utrecht-table>
<caption>
Gepubliceerde zaaktypen
</caption>
<thead>
<tr>
<th scope="col">Identificatie</th>
<th scope="col">Omschrijving</th>
</tr>
</thead>
<tbody>
@for (zaaktype of items(); track zaaktype.identificatie) {
<tr>
<td>{{ zaaktype.identificatie }}</td>
<td>{{ zaaktype.omschrijving }}</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>
@@ -1,75 +0,0 @@
import { signal } from '@angular/core';
import { render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { CatalogusPage } from './catalogus-page';
const sample: BeheerZaaktype[] = [
{ identificatie: 'BIG-REGISTRATIE', omschrijving: 'BIG-registratie' },
{ identificatie: 'BIG-HERREGISTRATIE', omschrijving: 'BIG-herregistratie' },
];
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['beheerder']);
login(): void {
/* not exercised here */
}
logout(): void {
/* not exercised here */
}
}
function setup(overrides: { getBeheerCatalogiZaaktypen?: ReturnType<typeof vi.fn> } = {}) {
const getBeheerCatalogiZaaktypen =
overrides.getBeheerCatalogiZaaktypen ?? vi.fn().mockReturnValue(of(sample));
return {
getBeheerCatalogiZaaktypen,
providers: [
{ provide: BffApiV1Service, useValue: { getBeheerCatalogiZaaktypen } },
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('CatalogusPage', () => {
it('lists the published zaaktypen on open', async () => {
const { getBeheerCatalogiZaaktypen, providers } = setup();
await render(CatalogusPage, { providers });
expect(getBeheerCatalogiZaaktypen).toHaveBeenCalled();
expect(await screen.findByText('BIG-REGISTRATIE')).toBeTruthy();
expect(screen.getByText('BIG-registratie')).toBeTruthy();
expect(screen.getByText('BIG-HERREGISTRATIE')).toBeTruthy();
});
it('shows an empty state when the catalogus has no published zaaktypen', async () => {
const { providers } = setup({ getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(of([])) });
await render(CatalogusPage, { providers });
expect(await screen.findByText(/geen gepubliceerde zaaktypen/i)).toBeTruthy();
});
it('surfaces a load failure instead of swallowing it', async () => {
const { providers } = setup({
getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(CatalogusPage, { providers });
expect(await screen.findByText(/kon de catalogus niet laden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(CatalogusPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -1,45 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The beheer catalogus viewer (S-15a): a signed-in beheerder sees the published ZTC zaaktypen,
* read-only. The list is served by the BFF (`GET /beheer/catalogi/zaaktypen`), which proxies the ACL —
* the only code allowed to read the ZGW Catalogi API (§8.1, ADR-0025). Managing default-fill is S-15b.
*/
@Component({
selector: 'app-catalogus-page',
imports: [UtrechtComponentsModule],
templateUrl: './catalogus-page.html',
})
export class CatalogusPage {
private readonly bff = inject(BffApiV1Service);
protected readonly items = signal<BeheerZaaktype[]>([]);
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly failed = signal(false);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.bff.getBeheerCatalogiZaaktypen().subscribe({
next: (rows: BeheerZaaktype[]) => {
this.items.set(rows);
this.loading.set(false);
this.loaded.set(true);
},
// Surface the failure (e.g. 403 for a non-beheerder) instead of swallowing it.
error: () => {
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
}
@@ -1,60 +0,0 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Default-fill</utrecht-heading-1>
<p utrecht-paragraph>
De ZGW-standaardwaarden die de ACL op elke nieuwe zaak invult (ADR-0003). Een wijziging geldt
voor de eerstvolgende zaak.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (loaded()) {
<form (submit)="save(); $event.preventDefault()">
<p>
<label for="bronorganisatie">Bronorganisatie</label><br />
<input
id="bronorganisatie"
name="bronorganisatie"
[value]="bronorganisatie()"
(input)="bronorganisatie.set($any($event.target).value)"
/>
</p>
<p>
<label for="verantwoordelijkeOrganisatie">Verantwoordelijke organisatie</label><br />
<input
id="verantwoordelijkeOrganisatie"
name="verantwoordelijkeOrganisatie"
[value]="verantwoordelijkeOrganisatie()"
(input)="verantwoordelijkeOrganisatie.set($any($event.target).value)"
/>
</p>
<p>
<label for="vertrouwelijkheidaanduiding">Vertrouwelijkheidaanduiding</label><br />
<input
id="vertrouwelijkheidaanduiding"
name="vertrouwelijkheidaanduiding"
[value]="vertrouwelijkheidaanduiding()"
(input)="vertrouwelijkheidaanduiding.set($any($event.target).value)"
/>
</p>
<button utrecht-button appearance="primary-action-button" type="submit" [disabled]="saving()">
Opslaan
</button>
</form>
@if (saved()) {
<p utrecht-paragraph role="status">De standaardwaarden zijn opgeslagen.</p>
}
@if (failed()) {
<p utrecht-paragraph role="alert">
Opslaan is niet gelukt. Controleer of je als beheerder bent ingelogd en probeer het opnieuw.
</p>
}
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de standaardwaarden niet laden. Controleer of je als beheerder bent ingelogd en probeer
het opnieuw.
</p>
}
</utrecht-article>
</main>
@@ -1,90 +0,0 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { DefaultFillPage } from './default-fill-page';
const current: BeheerDefaultFill = {
bronorganisatie: '517439943',
verantwoordelijkeOrganisatie: '517439943',
vertrouwelijkheidaanduiding: 'openbaar',
};
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['beheerder']);
login(): void {
/* not exercised */
}
logout(): void {
/* not exercised */
}
}
function setup(
overrides: {
getBeheerDefaultFill?: ReturnType<typeof vi.fn>;
putBeheerDefaultFill?: ReturnType<typeof vi.fn>;
} = {},
) {
const getBeheerDefaultFill = overrides.getBeheerDefaultFill ?? vi.fn().mockReturnValue(of(current));
const putBeheerDefaultFill = overrides.putBeheerDefaultFill ?? vi.fn().mockReturnValue(of(undefined));
return {
getBeheerDefaultFill,
putBeheerDefaultFill,
providers: [
{ provide: BffApiV1Service, useValue: { getBeheerDefaultFill, putBeheerDefaultFill } },
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('DefaultFillPage', () => {
it('loads the current default-fill into the form on open', async () => {
const { getBeheerDefaultFill, providers } = setup();
await render(DefaultFillPage, { providers });
expect(getBeheerDefaultFill).toHaveBeenCalled();
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
expect(bron.value).toBe('517439943');
});
it('saves the edited values via the BFF', async () => {
const { putBeheerDefaultFill, providers } = setup();
await render(DefaultFillPage, { providers });
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
fireEvent.input(bron, { target: { value: '999999999' } });
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }));
expect(putBeheerDefaultFill).toHaveBeenCalledWith(
expect.objectContaining({ bronorganisatie: '999999999', vertrouwelijkheidaanduiding: 'openbaar' }),
);
expect(await screen.findByText(/standaardwaarden zijn opgeslagen/i)).toBeTruthy();
});
it('surfaces a save failure instead of swallowing it', async () => {
const { providers } = setup({
putBeheerDefaultFill: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(DefaultFillPage, { providers });
fireEvent.click(await screen.findByRole('button', { name: /opslaan/i }));
expect(await screen.findByText(/opslaan is niet gelukt/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(DefaultFillPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -1,72 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The beheer default-fill editor (S-15b): a beheerder reads and edits the ZGW default-fill values the
* ACL stamps on every zaak (ADR-0003). Load and save go through the BFF (`/beheer/default-fill`),
* which proxies the ACL (ADR-0025). A save takes effect on the next zaak (the ACL reads it per zaak).
*/
@Component({
selector: 'app-default-fill-page',
imports: [UtrechtComponentsModule],
templateUrl: './default-fill-page.html',
})
export class DefaultFillPage {
private readonly bff = inject(BffApiV1Service);
protected readonly bronorganisatie = signal('');
protected readonly verantwoordelijkeOrganisatie = signal('');
protected readonly vertrouwelijkheidaanduiding = signal('');
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly saving = signal(false);
protected readonly failed = signal(false);
protected readonly saved = signal(false);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.saved.set(false);
this.bff.getBeheerDefaultFill().subscribe({
next: (d: BeheerDefaultFill) => {
this.bronorganisatie.set(d.bronorganisatie);
this.verantwoordelijkeOrganisatie.set(d.verantwoordelijkeOrganisatie);
this.vertrouwelijkheidaanduiding.set(d.vertrouwelijkheidaanduiding);
this.loading.set(false);
this.loaded.set(true);
},
error: () => {
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
save(): void {
this.saving.set(true);
this.failed.set(false);
this.saved.set(false);
this.bff
.putBeheerDefaultFill({
bronorganisatie: this.bronorganisatie(),
verantwoordelijkeOrganisatie: this.verantwoordelijkeOrganisatie(),
vertrouwelijkheidaanduiding: this.vertrouwelijkheidaanduiding(),
})
.subscribe({
next: () => {
this.saving.set(false);
this.saved.set(true);
},
error: () => {
this.saving.set(false);
this.failed.set(true);
},
});
}
}
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Beheerportaal BIG-register</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
-10
View File
@@ -1,10 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
// Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));
-2
View File
@@ -1,2 +0,0 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
-31
View File
@@ -1,31 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
-25
View File
@@ -1,25 +0,0 @@
# Multi-stage build for the openbaar portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/openbaar apps/openbaar
COPY libs libs
RUN pnpm nx build openbaar
FROM nginx:1.27-alpine AS runtime
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
-34
View File
@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
-23
View File
@@ -1,23 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the anonymous openbaar endpoint group to the bff service. The api-client
# uses relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS.
location /openbaar/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
-82
View File
@@ -1,82 +0,0 @@
{
"name": "openbaar",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/openbaar/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/openbaar",
"browser": "apps/openbaar/src/main.ts",
"tsConfig": "apps/openbaar/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/openbaar/public"
}
],
"styles": ["apps/openbaar/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "openbaar:build:production"
},
"development": {
"buildTarget": "openbaar:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "openbaar:build",
"staticFilePath": "dist/apps/openbaar/browser",
"spa": true
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-19
View File
@@ -1,19 +0,0 @@
import { provideHttpClient } from '@angular/common/http';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
/**
* The openbaar register is a public, anonymous read: no DigiD, no auth interceptor. The app is served
* same-origin as the BFF (nginx proxies /openbaar), so the api-client's relative calls stay same-origin.
*/
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(),
],
};
View File
-1
View File
@@ -1 +0,0 @@
<router-outlet></router-outlet>
-4
View File
@@ -1,4 +0,0 @@
import { Route } from '@angular/router';
import { RegisterPage } from './register/register-page';
export const appRoutes: Route[] = [{ path: '', component: RegisterPage }];
-14
View File
@@ -1,14 +0,0 @@
import { provideRouter } from '@angular/router';
import { render } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the RegisterPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
});
});
-12
View File
@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'openbaar';
}
@@ -1,54 +0,0 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Openbaar BIG-register</utrecht-heading-1>
<p utrecht-paragraph>
Zoek in het openbare register van BIG-registraties. Alleen publieke gegevens worden getoond.
</p>
<div role="search">
<label for="register-search" utrecht-form-label>Zoek op referentie</label>
<input
id="register-search"
type="search"
utrecht-textbox
[ngModel]="query()"
(ngModelChange)="query.set($event)"
[ngModelOptions]="{ standalone: true }"
(keyup.enter)="search()"
/>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="loading()"
(click)="search()"
>
Zoeken
</button>
</div>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (searched() && entries().length === 0) {
<p utrecht-paragraph role="status">Geen inschrijvingen gevonden.</p>
} @else if (entries().length > 0) {
<table utrecht-table>
<caption>Inschrijvingen in het openbaar register</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
@for (entry of entries(); track entry.id) {
<tr>
<td>{{ entry.reference }}</td>
<td>{{ entry.status }}</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>
@@ -1,59 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/angular';
import { of } from 'rxjs';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { axe } from 'vitest-axe';
import { RegisterPage } from './register-page';
const sample: OpenbaarEntry[] = [
{ id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' },
{ id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' },
];
function providers(get = vi.fn().mockReturnValue(of(sample))) {
return {
get,
providers: [{ provide: BffApiV1Service, useValue: { getOpenbaarRegister: get } }],
};
}
describe('RegisterPage', () => {
it('lists the public register entries from the BFF on open', async () => {
const { get } = providers();
await render(RegisterPage, { providers: providers(get).providers });
expect(get).toHaveBeenCalled();
// The Referentie column shows the citizen's reference (matches the submit confirmation, #78),
// not the internal zaak id.
expect(await screen.findByText(/REG-abc/)).toBeTruthy();
expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
expect(screen.getByText(/REG-def/)).toBeTruthy();
});
it('searches by the entered term', async () => {
const get = vi.fn().mockReturnValue(of(sample));
await render(RegisterPage, { providers: providers(get).providers });
fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'zaak-abc' } });
fireEvent.click(screen.getByRole('button', { name: /zoek/i }));
expect(get).toHaveBeenLastCalledWith({ q: 'zaak-abc' });
});
it('shows an empty-state message when the register has no matches', async () => {
const get = vi.fn().mockReturnValue(of([] as OpenbaarEntry[]));
await render(RegisterPage, { providers: providers(get).providers });
expect(await screen.findByText(/geen inschrijvingen gevonden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(RegisterPage, { providers: providers().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -1,45 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The openbaar (public) BIG-register: an anonymous search over the read projection's public-safe
* view (id + status only — bsn/naam never leave the BFF; ADR-0010). Loads the full register on open
* and filters by the search term via the BFF's `/openbaar/register?q=` endpoint (S-09).
*/
@Component({
selector: 'app-register-page',
imports: [FormsModule, UtrechtComponentsModule],
templateUrl: './register-page.html',
})
export class RegisterPage {
private readonly bff = inject(BffApiV1Service);
protected readonly query = signal('');
protected readonly entries = signal<OpenbaarEntry[]>([]);
protected readonly loading = signal(false);
protected readonly searched = signal(false);
constructor() {
// Show the full register on open; the search box narrows it.
this.search();
}
search(): void {
const q = this.query().trim();
this.loading.set(true);
this.bff.getOpenbaarRegister(q ? { q } : {}).subscribe({
next: (rows: OpenbaarEntry[]) => {
this.entries.set(rows);
this.loading.set(false);
this.searched.set(true);
},
error: () => {
this.entries.set([]);
this.loading.set(false);
this.searched.set(true);
},
});
}
}
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Openbaar BIG-register</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
// The openbaar register is anonymous (no DigiD, no runtime config) — bootstrap directly.
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
-2
View File
@@ -1,2 +0,0 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
-31
View File
@@ -1,31 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
#
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
#
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
# entrypoint executes or sources it.
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
echo "portal-nginx-resolver: set resolver to $ns"
fi
-27
View File
@@ -1,27 +0,0 @@
# Multi-stage build for the self-service portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/self-service apps/self-service
COPY libs libs
RUN pnpm nx build self-service
FROM nginx:1.27-alpine AS runtime
COPY apps/self-service/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
-34
View File
@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
-29
View File
@@ -1,29 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the BFF endpoint groups to the bff service. The api-client uses relative
# URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the DigiD
# token (same-origin) is attached by the app's interceptor (S-08d/ADR-0010).
location /self-service/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
location /openbaar/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
-82
View File
@@ -1,82 +0,0 @@
{
"name": "self-service",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/self-service/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/self-service",
"browser": "apps/self-service/src/main.ts",
"tsConfig": "apps/self-service/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/self-service/public"
}
],
"styles": ["apps/self-service/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "self-service:build:production"
},
"development": {
"buildTarget": "self-service:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "self-service:build",
"staticFilePath": "dist/apps/self-service/browser",
"spa": true
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/digid"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

@@ -1,65 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BffApiV1Service } from 'api-client';
import { authInterceptor } from 'auth';
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
import { SECURE_API_ROUTES } from './app.config';
// Guards the DigiD token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and the
// angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a configured
// secureRoute. A regression to an absolute origin (as once shipped) makes the relative URL never match,
// so the submit goes out unauthenticated and fails silently. This drives the REAL interceptor and the
// REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config source
// and the token storage are faked, so the assertion turns on the actual route-matching.
describe('self-service DigiD token wiring', () => {
let http: HttpTestingController;
let bff: BffApiV1Service;
const token = 'digid-access-token';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
{
provide: ConfigurationService,
useValue: {
hasAtLeastOneConfig: () => true,
getAllConfigurations: () => [{ configId: 'digid', secureRoutes: SECURE_API_ROUTES }],
},
},
{
// A signed-in session: the storage the interceptor's token lookup reads from.
provide: AbstractSecurityStorage,
useValue: {
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
write: () => undefined,
remove: () => undefined,
clear: () => undefined,
},
},
],
});
http = TestBed.inject(HttpTestingController);
bff = TestBed.inject(BffApiV1Service);
});
afterEach(() => http.verify());
it('attaches the bearer token to the relative self-service BFF call', () => {
bff.postSelfServiceRegistrations().subscribe();
const req = http.expectOne('/self-service/registrations');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush({ registrationId: 'reg-1', status: 'Ingediend' });
});
it('leaves the anonymous openbaar register call unauthenticated', () => {
bff.getOpenbaarRegister().subscribe();
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
});
-42
View File
@@ -1,42 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideDigiadAuth } from 'auth';
import { appRoutes } from './app.routes';
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `digid` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
authority: string;
}
/**
* Route prefixes whose requests carry the DigiD token. These MUST match the **relative** URLs the
* api-client actually calls (same-origin via the nginx proxy) the interceptor matches on `req.url`,
* which stays relative, so an absolute origin would never match and the token would go unattached.
* `/openbaar/` is deliberately excluded: it is the anonymous public register.
*/
export const SECURE_API_ROUTES = ['/self-service/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideDigiadAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}
View File
-1
View File
@@ -1 +0,0 @@
<router-outlet></router-outlet>
-7
View File
@@ -1,7 +0,0 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { RegistrationPage } from './registration/registration-page';
export const appRoutes: Route[] = [
{ path: '', component: RegistrationPage, canActivate: [authenticatedGuard] },
];
-15
View File
@@ -1,15 +0,0 @@
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the RegistrationPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});
-12
View File
@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'self-service';
}
@@ -1,74 +0,0 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
@if (submitted()) {
@if (withdrawn()) {
<p utrecht-paragraph role="status">
Uw registratie met referentie {{ reference() }} is ingetrokken.
</p>
} @else {
<p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p>
@if (documentsProvided()) {
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
} @else {
@if (provideDocumentsFailed()) {
<p utrecht-paragraph role="alert">
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
</p>
}
<p utrecht-paragraph>Lever uw diploma aan (PDF).</p>
<label utrecht-form-label for="diploma">Diploma</label>
<input
id="diploma"
type="file"
accept="application/pdf"
[disabled]="providingDocuments()"
(change)="onFileSelected($event)"
/>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="providingDocuments() || !selectedFile()"
(click)="provideDocuments()"
>
Documenten aanleveren
</button>
}
@if (withdrawFailed()) {
<p utrecht-paragraph role="alert">
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
</p>
}
<button
utrecht-button
appearance="secondary-action-button"
type="button"
[disabled]="withdrawing()"
(click)="withdraw()"
>
Trek aanvraag in
</button>
}
} @else {
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
@if (failed()) {
<p utrecht-paragraph role="alert">
Er ging iets mis bij het indienen van uw registratie. Probeer het opnieuw.
</p>
}
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="submitting()"
(click)="submit()"
>
Registratie indienen
</button>
}
</utrecht-article>
</main>
@@ -1,173 +0,0 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { AuthService } from 'auth';
import { BffApiV1Service } from 'api-client';
import { axe } from 'vitest-axe';
import { RegistrationPage } from './registration-page';
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>('123456782');
login(): void {
/* noop */
}
logout(): void {
/* noop */
}
}
function providers(
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
withdraw = vi.fn().mockReturnValue(of(undefined)),
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
// Resume lookup (S-26): default to 204/empty — no in-flight registration, so the submit form shows.
getCurrent = vi.fn().mockReturnValue(of(undefined)),
) {
return {
post,
withdraw,
provideDocuments,
getCurrent,
providers: [
{ provide: AuthService, useClass: FakeAuth },
{
provide: BffApiV1Service,
useValue: {
getSelfServiceRegistrations: getCurrent,
postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw,
postSelfServiceRegistrationsIdDocuments: provideDocuments,
},
},
],
};
}
describe('RegistrationPage', () => {
it('shows the signed-in BSN', async () => {
await render(RegistrationPage, { providers: providers().providers });
expect(screen.getByText(/123456782/)).toBeTruthy();
});
it('submits the registration and confirms', async () => {
const { post, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
expect(post).toHaveBeenCalledTimes(1);
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
});
it('resumes an existing registration on load, without submitting again (S-26)', async () => {
const { post, providers: p } = providers(
undefined,
undefined,
undefined,
vi.fn().mockReturnValue(of({ registrationId: 'reg-77', status: 'Ingediend' })),
);
await render(RegistrationPage, { providers: p });
// The confirmation view is restored from the in-flight registration — no submit click.
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
expect(screen.getByText(/reg-77/)).toBeTruthy();
expect(post).not.toHaveBeenCalled();
});
it('shows an error and keeps the submit available when the BFF call fails', async () => {
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
expect(post).toHaveBeenCalledTimes(1);
// The failure is surfaced (not swallowed), the confirmation is not shown, and the user can retry.
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/ontvangen/i)).toBeNull();
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
});
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
const { withdraw, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
expect(withdraw).toHaveBeenCalledWith('reg-9');
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
});
// A small PDF file the citizen "uploads"; the component base64-encodes it client-side.
const diploma = () => new File([new Uint8Array([1, 2, 3])], 'diploma.pdf', { type: 'application/pdf' });
it('uploads a chosen diploma after submitting, and doing so confirms', async () => {
const { provideDocuments, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
// Choose the file, then upload it.
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
// The upload is keyed by the reference and carries the base64 file + its name; the page confirms.
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
expect(provideDocuments).toHaveBeenCalledWith(
'reg-9',
expect.objectContaining({ fileName: 'diploma.pdf', contentType: 'application/pdf', contentBase64: expect.any(String) }),
);
});
it('surfaces a diploma-upload failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(of(undefined)),
vi.fn().mockReturnValue(throwError(() => new Error('documents rejected'))),
);
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
});
it('surfaces a withdraw failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
);
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
});
it('has no WCAG 2.1 AA violations on the submit page', async () => {
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
// html-has-lang rule reflects the app, not the bare jsdom document.
document.documentElement.lang = 'nl';
const { container } = await render(RegistrationPage, { providers: providers().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -1,140 +0,0 @@
import { Component, inject, type OnInit, signal } from '@angular/core';
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
import { AuthService } from 'auth';
import { UtrechtComponentsModule } from 'ui';
/**
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
* submitting they can withdraw it "trek aanvraag in" keyed by that reference (S-11c).
*
* On load it asks the BFF for the caller's current open registration and restores the submitted view
* if there is one, so a page refresh no longer strands an in-flight registration (S-26).
*/
@Component({
selector: 'app-registration-page',
imports: [UtrechtComponentsModule],
templateUrl: './registration-page.html',
})
export class RegistrationPage implements OnInit {
private readonly auth = inject(AuthService);
private readonly bff = inject(BffApiV1Service);
protected readonly bsn = this.auth.bsn;
protected readonly submitting = signal(false);
protected readonly reference = signal<string | undefined>(undefined);
protected readonly submitted = signal(false);
protected readonly failed = signal(false);
protected readonly withdrawing = signal(false);
protected readonly withdrawn = signal(false);
protected readonly withdrawFailed = signal(false);
protected readonly providingDocuments = signal(false);
protected readonly documentsProvided = signal(false);
protected readonly provideDocumentsFailed = signal(false);
protected readonly selectedFile = signal<File | undefined>(undefined);
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
* current open registration, or 204 (empty body) when there is none in which case we show the
* submit form as before. Failures are non-fatal for the same reason. */
ngOnInit(): void {
this.bff.getSelfServiceRegistrations().subscribe({
next: (current: CurrentRegistration | void) => {
if (current && current.registrationId) {
this.reference.set(current.registrationId);
this.submitted.set(true);
}
},
error: () => {
// No resumable registration (or the lookup failed) — fall back to the submit form.
},
});
}
submit(): void {
this.submitting.set(true);
this.failed.set(false);
this.bff.postSelfServiceRegistrations().subscribe({
next: (accepted: SubmitAccepted) => {
this.reference.set(accepted.registrationId);
this.submitted.set(true);
this.submitting.set(false);
},
// Surface the failure instead of swallowing it: re-enable the button so the user can retry.
error: () => {
this.failed.set(true);
this.submitting.set(false);
},
});
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
this.selectedFile.set(input.files?.[0] ?? undefined);
}
async provideDocuments(): Promise<void> {
const reference = this.reference();
const file = this.selectedFile();
if (!reference || !file) {
return;
}
this.providingDocuments.set(true);
this.provideDocumentsFailed.set(false);
let contentBase64: string;
try {
contentBase64 = await readAsBase64(file);
} catch {
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
return;
}
this.bff
.postSelfServiceRegistrationsIdDocuments(reference, {
contentBase64,
fileName: file.name,
contentType: file.type || 'application/pdf',
})
.subscribe({
next: () => {
this.documentsProvided.set(true);
this.providingDocuments.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
},
});
}
withdraw(): void {
const reference = this.reference();
if (!reference) {
return;
}
this.withdrawing.set(true);
this.withdrawFailed.set(false);
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
next: () => {
this.withdrawn.set(true);
this.withdrawing.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.withdrawFailed.set(true);
this.withdrawing.set(false);
},
});
}
}
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
function readAsBase64(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(((reader.result as string) ?? '').split(',', 2)[1] ?? '');
reader.onerror = () => reject(reader.error ?? new Error('Could not read the file.'));
reader.readAsDataURL(file);
});
}
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>self-service</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
-10
View File
@@ -1,10 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
// Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));
-2
View File
@@ -1,2 +0,0 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
-31
View File
@@ -1,31 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+1 -1
View File
@@ -207,7 +207,7 @@ A slice is done when:
## 15. Out of scope for v1
- OpenMetadata data governance module (v3 slice).
- ~~Objecten as the authoritative register record store~~**delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder.
- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder).
- Production-grade Helm chart (sketch only).
- Multi-tenancy.
- Real outbound notifications (email/SMS) — logged to console in v1.

Some files were not shown because too many files have changed in this diff Show More