feat(behandel): behandel-portal — werkbak + beoordeling (closes #13) (#87)
All checks were successful
CI / lint (push) Successful in 1m14s
CI / build (push) Successful in 53s
CI / unit (push) Successful in 1m3s
CI / frontend (push) Successful in 2m30s
CI / mutation (push) Successful in 4m59s
CI / verify-stack (push) Successful in 7m5s

## What & why

Finishes **S-12 · Behandel-portal — werkbak + beoordeling**. The backend sub-slices (S-12a/b/c-1/c-2) were merged, but the slice's stated outcome — a behandel *portal* with medewerker login, a werkbak, and decide — had no frontend. This adds it.

- **`libs/auth`**: `MedewerkerAuthService` + `provideMedewerkerAuth` (Keycloak `medewerker` realm), a `roles`/`hasRole` surface on the shared `AuthService`, and a realm-roles protocol mapper so the SPA can read `behandelaar`/`teamlead` from the token. The BFF remains the security boundary (ADR-0013).
- **`apps/behandel`**: a new Nx Angular app mirroring self-service — medewerker OIDC login and a **werkbak** page listing registrations awaiting beoordeling (`GET /behandel/werkbak`) with per-row **Goedkeuren/Afwijzen** actions (`POST /behandel/registrations/{id}/decide`) that refresh the list. NL DS/Utrecht, standalone + signals.
- **e2e**: the walking-skeleton happy path now approves through the real portal (behandelaar logs in, finds the row by reference, clicks Goedkeuren) instead of the temporary admin endpoint.
- **infra/docs**: behandel service in compose (`:8142`, depends on Keycloak); added to the smoke `WAIT_SVCS` + CI log dump; `frontend-decisions.md` and `demo-script.md` updated.

Closes #13

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation.
- [x] Implementation makes the test pass; refactor commit if structure improved.
- [x] Conventional Commits referencing the issue (`refs #13`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes. *(behandel image + container verified locally; full stack gated in CI.)*
- [x] Docs updated if behaviour, contracts, or operations changed.
- [x] ADR added — ADR-0013 (merged with the backend sub-slices) already covers the wiring; no new decision here.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- Verified locally: auth + behandel + all frontend projects pass lint & unit tests (incl. axe WCAG 2.1 AA); production build green; the behandel Docker image builds and serves with the correct baked `medewerker` config + SPA fallback.
- The full compose-up smoke, e2e, and mutation are CI-gated (known local full-stack verify limits).
- **Follow-ups (not in scope):** the `WerkbakItem` contract has no citizen name (werkbak shows the BSN) — adding one is a BFF+domain contract change; and the domain's temporary admin `approve` endpoint is now unused by the e2e and could be removed.

Reviewed-on: #87
This commit was merged in pull request #87.
This commit is contained in:
2026-07-16 08:31:57 +00:00
parent d226b6402d
commit 3abf8f7ccf
35 changed files with 918 additions and 24 deletions

View File

@@ -1,4 +1,6 @@
export * from './lib/auth.service';
export * from './lib/digid-auth.service';
export * from './lib/digid-auth.providers';
export * from './lib/medewerker-auth.service';
export * from './lib/medewerker-auth.providers';
export * from './lib/authenticated.guard';

View File

@@ -1,16 +1,26 @@
import { Signal } from '@angular/core';
import { signal, Signal } from '@angular/core';
/**
* The portal's view of the signed-in user. An abstraction over the OIDC library so components and
* guards depend on a small, mockable surface (the real implementation is DigiadAuthService).
* guards depend on a small, mockable surface (the real implementations are DigiadAuthService for
* citizens and MedewerkerAuthService for staff).
*/
export abstract class AuthService {
/** Whether a DigiD session is active. */
/** Whether a session is active. */
abstract readonly isAuthenticated: Signal<boolean>;
/** The citizen-service number from the DigiD token, once authenticated. */
/** The citizen-service number from the DigiD token, once authenticated (staff have none). */
abstract readonly bsn: Signal<string | undefined>;
/** Start the DigiD login (redirects to Keycloak). */
/**
* The realm roles carried in the token. Empty for realms that don't grant roles (e.g. `digid`);
* the `medewerker` realm carries `behandelaar`/`teamlead`.
*/
readonly roles: Signal<readonly string[]> = signal<readonly string[]>([]);
/** Start login (redirects to Keycloak). */
abstract login(): void;
/** End the session. */
abstract logout(): void;
/** Whether the signed-in user holds the given realm role. */
hasRole(role: string): boolean {
return this.roles().includes(role);
}
}

View File

@@ -0,0 +1,49 @@
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { LogLevel, provideAuth, withAppInitializerAuthCheck } from 'angular-auth-oidc-client';
import { AuthService } from './auth.service';
import { MedewerkerAuthService } from './medewerker-auth.service';
export interface MedewerkerAuthOptions {
/** The Keycloak `medewerker` realm issuer, as reachable from the browser. */
authority: string;
/** Where Keycloak redirects back to after login (usually the app origin). */
redirectUrl: string;
/**
* Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
* (e.g. `/behandel/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
* relative `req.url` never starts with an absolute origin.
*/
secureRoutes: string[];
}
/**
* Configure medewerker login (Keycloak `medewerker` realm, public client `big-portal`, auth-code +
* PKCE) and bind {@link AuthService} to the medewerker-backed implementation. Register
* {@link authInterceptor} (re-exported from digid-auth.providers) in the app's HttpClient so BFF
* calls carry the token.
*/
export function provideMedewerkerAuth(options: MedewerkerAuthOptions): EnvironmentProviders {
return makeEnvironmentProviders([
provideAuth(
{
config: {
authority: options.authority,
redirectUrl: options.redirectUrl,
postLogoutRedirectUri: options.redirectUrl,
clientId: 'big-portal',
scope: 'openid profile',
responseType: 'code',
silentRenew: true,
useRefreshToken: true,
secureRoutes: options.secureRoutes,
logLevel: LogLevel.Warn,
},
},
// Run checkAuth() at startup so the login callback (?code=…) is processed before the router
// and guard run — without it the guard sees "not authenticated" and re-triggers login (loop).
withAppInitializerAuthCheck(),
),
{ provide: AuthService, useClass: MedewerkerAuthService },
]);
}

View File

@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { of } from 'rxjs';
import { MedewerkerAuthService } from './medewerker-auth.service';
function makeService(userData: unknown, authenticated = true) {
const oidc = {
isAuthenticated$: of({ isAuthenticated: authenticated }),
userData$: of({ userData }),
authorize: vi.fn(),
logoff: vi.fn(() => of(null)),
};
TestBed.configureTestingModule({
providers: [MedewerkerAuthService, { provide: OidcSecurityService, useValue: oidc }],
});
return { svc: TestBed.inject(MedewerkerAuthService), oidc };
}
describe('MedewerkerAuthService', () => {
it('exposes the realm roles carried in the token', () => {
const { svc } = makeService({ realm_access: { roles: ['behandelaar', 'teamlead'] } });
expect(svc.roles()).toEqual(['behandelaar', 'teamlead']);
expect(svc.hasRole('behandelaar')).toBe(true);
expect(svc.hasRole('beheerder')).toBe(false);
});
it('has no roles when the token omits realm_access', () => {
const { svc } = makeService({ preferred_username: 'merel-behandelaar' });
expect(svc.roles()).toEqual([]);
expect(svc.hasRole('behandelaar')).toBe(false);
});
it('reflects the OIDC authenticated state', () => {
const { svc } = makeService({}, true);
expect(svc.isAuthenticated()).toBe(true);
});
it('starts login by delegating to the OIDC library', () => {
const { svc, oidc } = makeService({});
svc.login();
expect(oidc.authorize).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,41 @@
import { inject, Injectable, Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { map } from 'rxjs';
import { AuthService } from './auth.service';
/** The subset of the medewerker token the portal reads: Keycloak nests realm roles here. */
interface MedewerkerClaims {
realm_access?: { roles?: string[] };
}
/** Medewerker-backed AuthService over angular-auth-oidc-client (Keycloak `medewerker` realm). */
@Injectable()
export class MedewerkerAuthService extends AuthService {
private readonly oidc = inject(OidcSecurityService);
readonly isAuthenticated: Signal<boolean> = toSignal(
this.oidc.isAuthenticated$.pipe(map((result) => result.isAuthenticated)),
{ initialValue: false },
);
// Staff have no BSN; the abstract surface keeps this present for the shared guard/interceptor.
readonly bsn: Signal<string | undefined> = toSignal(this.oidc.userData$.pipe(map(() => undefined)), {
initialValue: undefined,
});
override readonly roles: Signal<readonly string[]> = toSignal(
this.oidc.userData$.pipe(
map((data) => (data.userData as MedewerkerClaims | null)?.realm_access?.roles ?? []),
),
{ initialValue: [] },
);
override login(): void {
this.oidc.authorize();
}
override logout(): void {
this.oidc.logoff().subscribe();
}
}