diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index eff1cd1..4e3a624 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -124,10 +124,12 @@ jobs:
run: make verify-domain
- name: BFF → Keycloak + domain + projection
run: make verify-bff
+ - name: Self-service e2e (Playwright, login → submit → success)
+ run: make verify-e2e
# 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 2>&1 || true
+ 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 2>&1 || true
- name: Tear down
if: always()
run: make down
diff --git a/.gitignore b/.gitignore
index c206ae3..30b841d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,8 @@ 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/
diff --git a/Makefile b/Makefile
index 01b48ae..3075548 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ 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
+WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service
# 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
@@ -50,9 +50,16 @@ endif
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 build
+ pnpm nx run-many -t lint test
+ pnpm nx run-many -t build
## lint: verify formatting (no changes)
lint:
@@ -153,6 +160,11 @@ verify-domain:
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: 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.
@@ -165,7 +177,8 @@ verify:
&& bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \
&& bash infra/run-domain-check.sh \
- && bash infra/run-bff-check.sh || rc=$$?; \
+ && 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'
diff --git a/apps/self-service/Dockerfile b/apps/self-service/Dockerfile
new file mode 100644
index 0000000..6f72a08
--- /dev/null
+++ b/apps/self-service/Dockerfile
@@ -0,0 +1,23 @@
+# 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
+
+EXPOSE 80
diff --git a/apps/self-service/nginx.conf b/apps/self-service/nginx.conf
new file mode 100644
index 0000000..d399eee
--- /dev/null
+++ b/apps/self-service/nginx.conf
@@ -0,0 +1,29 @@
+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;
+ }
+}
diff --git a/apps/self-service/public/config.json b/apps/self-service/public/config.json
new file mode 100644
index 0000000..7642cac
--- /dev/null
+++ b/apps/self-service/public/config.json
@@ -0,0 +1,3 @@
+{
+ "authority": "http://localhost:8180/realms/digid"
+}
diff --git a/apps/self-service/src/app/app.config.spec.ts b/apps/self-service/src/app/app.config.spec.ts
new file mode 100644
index 0000000..439e411
--- /dev/null
+++ b/apps/self-service/src/app/app.config.spec.ts
@@ -0,0 +1,65 @@
+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([]);
+ });
+});
diff --git a/apps/self-service/src/app/app.config.ts b/apps/self-service/src/app/app.config.ts
index d7a7072..06382d2 100644
--- a/apps/self-service/src/app/app.config.ts
+++ b/apps/self-service/src/app/app.config.ts
@@ -7,16 +7,36 @@ import { provideRouter } from '@angular/router';
import { authInterceptor, provideDigiadAuth } from 'auth';
import { appRoutes } from './app.routes';
-export const appConfig: ApplicationConfig = {
- providers: [
- provideBrowserGlobalErrorListeners(),
- provideRouter(appRoutes),
- provideHttpClient(withInterceptors([authInterceptor()])),
- // Dev defaults (host ports). The compose-served app overrides these for the stack (S-08d).
- provideDigiadAuth({
- authority: 'http://localhost:8180/realms/digid',
- redirectUrl: typeof window !== 'undefined' ? window.location.origin : '/',
- secureApiOrigin: 'http://localhost:8080',
- }),
- ],
-};
+/** 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,
+ }),
+ ],
+ };
+}
diff --git a/apps/self-service/src/app/registration/registration-page.html b/apps/self-service/src/app/registration/registration-page.html
index 3aa25cf..a8b6627 100644
--- a/apps/self-service/src/app/registration/registration-page.html
+++ b/apps/self-service/src/app/registration/registration-page.html
@@ -8,6 +8,11 @@
} @else {
U bent ingelogd met BSN {{ bsn() }}.
+ @if (failed()) {
+
+ Er ging iets mis bij het indienen van uw registratie. Probeer het opnieuw.
+
+ }