feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Session } from '../domain/session';
|
||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||
|
||||
const STORAGE_KEY = 'session-v1';
|
||||
|
||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
||||
G2: validate the shape before trusting it. G1: the BSN is never persisted
|
||||
(see the effect below), so a restored session carries an empty one — it is
|
||||
unused after login; only `naam` is shown in the chrome. */
|
||||
function restore(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
||||
* there is exactly one instance — every component that injects it sees the same
|
||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
||||
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
|
||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
||||
* in an httpOnly cookie/token, not web storage.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStore {
|
||||
private digid = inject(DigidAdapter);
|
||||
private _session = signal<Session | null>(restore());
|
||||
|
||||
readonly session = this._session.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
/** Effectful command: authenticate, then store the session on success. */
|
||||
async login(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = await this.digid.authenticate(bsn);
|
||||
if (r.ok) this._session.set(r.value);
|
||||
return r;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this._session.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { SessionStore } from './application/session.store';
|
||||
import { authGuard, capabilityGuard } from './auth.guard';
|
||||
|
||||
type Opts = {
|
||||
authed: boolean;
|
||||
can?: (c: string) => boolean;
|
||||
whenReady?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function setup({ authed, can = () => false, whenReady = () => Promise.resolve() }: Opts) {
|
||||
const createUrlTree = vi.fn((cmds: string[]) => ({ tree: cmds }));
|
||||
const readySpy = vi.fn(whenReady);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: SessionStore, useValue: { isAuthenticated: () => authed } },
|
||||
{ provide: AccessStore, useValue: { whenReady: readySpy, can } },
|
||||
{ provide: Router, useValue: { createUrlTree } },
|
||||
],
|
||||
});
|
||||
return { createUrlTree, readySpy };
|
||||
}
|
||||
|
||||
// The guards ignore their (route, state) args; cast to call with none.
|
||||
const call = <T>(fn: unknown) => TestBed.runInInjectionContext(() => (fn as () => T)());
|
||||
|
||||
describe('authGuard', () => {
|
||||
it('allows an authenticated user', () => {
|
||||
setup({ authed: true });
|
||||
expect(call(authGuard)).toBe(true);
|
||||
});
|
||||
|
||||
it('redirects an anonymous user to /login', () => {
|
||||
const { createUrlTree } = setup({ authed: false });
|
||||
expect(call(authGuard)).toEqual({ tree: ['/login'] });
|
||||
expect(createUrlTree).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilityGuard', () => {
|
||||
const guard = () => capabilityGuard('stamdata:edit');
|
||||
|
||||
it('waits for /me, then allows an entitled admin', async () => {
|
||||
const { readySpy } = setup({ authed: true, can: (c) => c === 'stamdata:edit' });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toBe(true);
|
||||
expect(readySpy).toHaveBeenCalledOnce(); // it awaited caps before deciding
|
||||
});
|
||||
|
||||
it('sends an authenticated-but-unentitled user to /dashboard (not a login loop)', async () => {
|
||||
setup({ authed: true, can: () => false });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/dashboard'] });
|
||||
});
|
||||
|
||||
it('redirects an anonymous user to /login without waiting for caps', async () => {
|
||||
const { readySpy } = setup({ authed: false, can: () => true });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
|
||||
expect(readySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { SessionStore } from './application/session.store';
|
||||
|
||||
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const store = inject(SessionStore);
|
||||
const router = inject(Router);
|
||||
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
|
||||
};
|
||||
|
||||
/**
|
||||
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
|
||||
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
|
||||
*
|
||||
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
|
||||
* is still loading — it would deny an entitled admin and bounce them. We await
|
||||
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
|
||||
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
|
||||
* logged in, just not allowed here — no re-login loop). The backend re-enforces
|
||||
* regardless (403); this guard is the UX pre-gate.
|
||||
*/
|
||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
||||
return async () => {
|
||||
const session = inject(SessionStore);
|
||||
const access = inject(AccessStore);
|
||||
const router = inject(Router);
|
||||
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
|
||||
await access.whenReady();
|
||||
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Who is logged in. Framework-free domain type. */
|
||||
export interface Session {
|
||||
readonly bsn: string;
|
||||
readonly naam: string;
|
||||
}
|
||||
|
||||
export function isAuthenticated(s: Session | null): s is Session {
|
||||
return s !== null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Result, ok } from '@shared/kernel/fp';
|
||||
import { parseBsn } from '@shared/kernel/bsn';
|
||||
import { Session } from '../domain/session';
|
||||
|
||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DigidAdapter {
|
||||
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
||||
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
|
||||
// for a real OIDC redirect flow when there's an IdP.
|
||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = parseBsn(bsn);
|
||||
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Component, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
||||
@Component({
|
||||
selector: 'app-login-form',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
||||
template: `
|
||||
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
|
||||
<div class="form-header">
|
||||
<div class="form-action">
|
||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-form-field
|
||||
i18n-label="@@login.bsnLabel"
|
||||
label="BSN"
|
||||
fieldId="bsn"
|
||||
required
|
||||
i18n-description="@@login.bsnDescription"
|
||||
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="bsn"
|
||||
hasDescription
|
||||
[(ngModel)]="bsn"
|
||||
name="bsn"
|
||||
placeholder="123456782"
|
||||
/>
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
|
||||
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
|
||||
</app-form-field>
|
||||
|
||||
<app-button type="submit" variant="primary" i18n="@@login.submit"
|
||||
>Inloggen met DigiD</app-button
|
||||
>
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
export class LoginFormComponent {
|
||||
bsn = '';
|
||||
password = '';
|
||||
submitted = output<string>();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LoginFormComponent } from './login-form.component';
|
||||
|
||||
const meta: Meta<LoginFormComponent> = {
|
||||
title: 'Domein/Auth/Login Form',
|
||||
component: LoginFormComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LoginFormComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login-page',
|
||||
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
|
||||
template: `
|
||||
<app-page-shell
|
||||
i18n-heading="@@login.heading"
|
||||
heading="Inloggen"
|
||||
width="narrow"
|
||||
i18n-intro="@@login.intro"
|
||||
intro="Log in op uw persoonlijke BIG-register omgeving."
|
||||
>
|
||||
@if (error()) {
|
||||
<app-alert type="error">{{ error() }}</app-alert>
|
||||
}
|
||||
<app-login-form (submitted)="login($event)" />
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class LoginPage {
|
||||
private store = inject(SessionStore);
|
||||
private router = inject(Router);
|
||||
error = signal('');
|
||||
|
||||
async login(bsn: string) {
|
||||
const r = await this.store.login(bsn);
|
||||
if (r.ok) this.router.navigate(['/dashboard']);
|
||||
else this.error.set(r.error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user