feat(privacy): WP-40 — PII kernel (Bsn value object + masked-value atom)
Branded Bsn value object with the elfproef (11-test) checksum in shared/kernel/bsn.ts,
wired into the DigiD login boundary so login does real BSN validation (hint + e2e BSNs
updated to a valid 123456782). Consolidate the pure maskers into shared/kernel/pii.ts
(maskBsn/maskTail/REDACTED); debug-state keeps redactProfile (needs the registratie
BigProfile — boundary). New <app-masked-value> atom (+story) centralises the masked
`.includes('*')` detection + reveal affordance; behandel-scherm refactored onto it.
Session.bsn stays string (persistence boundary drops it for privacy). +specs for bsn/pii.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -84,7 +84,7 @@ for its existing violations, so every WP ends green.
|
|||||||
| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done |
|
| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done |
|
||||||
| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done |
|
| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done |
|
||||||
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
|
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
|
||||||
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | todo |
|
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
|
||||||
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | todo |
|
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | todo |
|
||||||
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | todo |
|
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | todo |
|
||||||
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo |
|
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo |
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
# WP-40 — PII kernel: branded `Bsn` VO + masked-value atom
|
# WP-40 — PII kernel: branded `Bsn` VO + masked-value atom
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 8 — platform/DX/showcase
|
Phase: 8 — platform/DX/showcase
|
||||||
Priority: P2
|
Priority: P2
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- `shared/kernel/bsn.ts` — `Bsn` branded VO + `parseBsn` with the **elfproef** (11-test) checksum
|
||||||
|
(+spec). Wired into the DigiD login boundary (`digid.adapter`): login now does real BSN
|
||||||
|
validation instead of "any 9 digits" — login hint + e2e BSNs updated to a valid one (`123456782`).
|
||||||
|
- `shared/kernel/pii.ts` — pure `maskBsn`/`maskTail`/`REDACTED` (+spec), the functional core. Moved
|
||||||
|
out of `debug-state/mask.ts`, which keeps only `redactProfile` (it depends on the registratie
|
||||||
|
`BigProfile`, so it can't live in `shared/kernel` — boundary; debug-state is the sanctioned
|
||||||
|
cross-context devtool). `mask.spec` ported to `pii.spec`.
|
||||||
|
- `<app-masked-value>` atom (`shared/ui/masked-value`) + story — masked-by-default value with an
|
||||||
|
optional reveal affordance; **centralises** the `.includes('*')` masked-detection that
|
||||||
|
`behandel-scherm` used to sniff inline (now refactored to the atom). The atom only emits
|
||||||
|
`reveal`; the caller keeps the step-up confirm + audited fetch.
|
||||||
|
- Deviation (flagged): `Session.bsn` stays typed `string` — the brand guards the login ENTRY, but
|
||||||
|
Session crosses a persistence boundary where the restore path deliberately drops the bsn
|
||||||
|
(privacy) and JSON can't carry a brand, so re-typing it would add a `''`/nullable ripple for no
|
||||||
|
real gain. Backend `MaskTail` twin unchanged (still in sync).
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Masking is real but ad-hoc: pure helpers live in `debug-state/mask.ts`, the BSN is a bare `string`
|
Masking is real but ad-hoc: pure helpers live in `debug-state/mask.ts`, the BSN is a bare `string`
|
||||||
@@ -29,6 +47,6 @@ AVG art. 9 (BSN = special category) territory; the pieces should be first-class
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `parseBsn` accepts valid elfproef numbers, rejects bad checksum/length (spec).
|
- [x] `parseBsn` accepts valid elfproef numbers, rejects bad checksum/length/all-zeros (spec).
|
||||||
- [ ] `<app-masked-value>` renders masked by default with an accessible reveal; behandel-scherm uses it.
|
- [x] `<app-masked-value>` renders masked by default with an optional reveal; behandel-scherm uses it (no more inline sniff).
|
||||||
- [ ] Pure maskers have specs (no TestBed); backend `MaskTail` parity noted; `npm run ci` green.
|
- [x] Pure maskers have specs (no TestBed); backend `MaskTail` parity noted; `npm run ci` green.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
// here: a real reload cycle, not a no-op button.
|
// here: a real reload cycle, not a no-op button.
|
||||||
test('dashboard error state renders, retry re-fetches (and fails again)', async ({ page }) => {
|
test('dashboard error state renders, retry re-fetches (and fails again)', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('BSN').fill('123456789');
|
await page.getByLabel('BSN').fill('123456782');
|
||||||
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
|
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
// stricter future test might.
|
// stricter future test might.
|
||||||
test('login → dashboard → registratie wizard → submitted', async ({ page }) => {
|
test('login → dashboard → registratie wizard → submitted', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('BSN').fill('123456789');
|
await page.getByLabel('BSN').fill('123456782');
|
||||||
await page.getByLabel('Wachtwoord').fill('demo');
|
await page.getByLabel('Wachtwoord').fill('demo');
|
||||||
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
|
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok } from '@shared/kernel/fp';
|
||||||
|
import { parseBsn } from '@shared/kernel/bsn';
|
||||||
import { Session } from '../domain/session';
|
import { Session } from '../domain/session';
|
||||||
|
|
||||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DigidAdapter {
|
export class DigidAdapter {
|
||||||
// ponytail: fake DigiD — any 9-digit BSN authenticates to a fixed identity.
|
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
||||||
// Swap for a real OIDC redirect flow when there's a backend.
|
// 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>> {
|
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||||
const t = bsn.trim();
|
const r = parseBsn(bsn);
|
||||||
if (!/^\d{9}$/.test(t))
|
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||||
return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
|
|
||||||
return ok({ bsn: t, naam: 'Dr. A. (Anna) de Vries' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
|||||||
fieldId="bsn"
|
fieldId="bsn"
|
||||||
required
|
required
|
||||||
i18n-description="@@login.bsnDescription"
|
i18n-description="@@login.bsnDescription"
|
||||||
description="9 cijfers (demo: vul iets in)"
|
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
|
||||||
>
|
>
|
||||||
<app-text-input
|
<app-text-input
|
||||||
inputId="bsn"
|
inputId="bsn"
|
||||||
hasDescription
|
hasDescription
|
||||||
[(ngModel)]="bsn"
|
[(ngModel)]="bsn"
|
||||||
name="bsn"
|
name="bsn"
|
||||||
placeholder="123456789"
|
placeholder="123456782"
|
||||||
/>
|
/>
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component, ElementRef, computed, input, output, viewChild } from '@angu
|
|||||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
||||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||||
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
|
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
|
||||||
import { inferSelection } from '@brief/domain/besluit';
|
import { inferSelection } from '@brief/domain/besluit';
|
||||||
@@ -25,6 +26,7 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
|
|||||||
imports: [
|
imports: [
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
HeadingComponent,
|
HeadingComponent,
|
||||||
|
MaskedValueComponent,
|
||||||
StepperComponent,
|
StepperComponent,
|
||||||
LetterCanvasComponent,
|
LetterCanvasComponent,
|
||||||
DiagnosticsPanelComponent,
|
DiagnosticsPanelComponent,
|
||||||
@@ -92,10 +94,13 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
|
|||||||
<span>{{ caseContext().aanvraagReferentie }}</span>
|
<span>{{ caseContext().aanvraagReferentie }}</span>
|
||||||
<span>{{ caseContext().zorgverlenerNaam }}</span>
|
<span>{{ caseContext().zorgverlenerNaam }}</span>
|
||||||
<span>
|
<span>
|
||||||
{{ bigLabel() }} {{ caseContext().bigNummer }}
|
{{ bigLabel() }}
|
||||||
@if (canRevealBigNummer() && isMasked()) {
|
<app-masked-value
|
||||||
<app-button variant="subtle" (click)="onReveal()">{{ revealLabel() }}</app-button>
|
[value]="caseContext().bigNummer"
|
||||||
}
|
[canReveal]="canRevealBigNummer()"
|
||||||
|
[revealLabel]="revealLabel()"
|
||||||
|
(reveal)="onReveal()"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ caseContext().beroep }}</span>
|
<span>{{ caseContext().beroep }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,10 +177,6 @@ export class BehandelSchermComponent {
|
|||||||
locate = output<Diagnostic>();
|
locate = output<Diagnostic>();
|
||||||
revealBigNummer = output<void>();
|
revealBigNummer = output<void>();
|
||||||
|
|
||||||
/** The BIG-nummer arrives masked (contains `*`); once revealed the swapped value has
|
|
||||||
no `*`, so the reveal action hides itself — no separate "revealed" flag needed. */
|
|
||||||
protected isMasked = computed(() => this.caseContext().bigNummer.includes('*'));
|
|
||||||
|
|
||||||
/** Step-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture
|
/** Step-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture
|
||||||
before an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth. */
|
before an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth. */
|
||||||
protected onReveal() {
|
protected onReveal() {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseBsn } from './bsn';
|
||||||
|
|
||||||
|
describe('parseBsn (elfproef)', () => {
|
||||||
|
it('accepts a valid BSN (passes the elfproef)', () => {
|
||||||
|
const r = parseBsn('123456782'); // Σ d·w = 154, divisible by 11
|
||||||
|
expect(r.ok && r.value).toBe('123456782');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a 9-digit number that fails the elfproef', () => {
|
||||||
|
expect(parseBsn('123456789').ok).toBe(false); // sum 147, not divisible
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong length / non-digits / all zeros', () => {
|
||||||
|
expect(parseBsn('12345').ok).toBe(false);
|
||||||
|
expect(parseBsn('abcdefghi').ok).toBe(false);
|
||||||
|
expect(parseBsn('000000000').ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Brand, Result, ok, err } from '@shared/kernel/fp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value object: a Dutch **BSN** (burgerservicenummer) — art. 9 GDPR/AVG special-category
|
||||||
|
* data. "Parse, don't validate": a `Bsn` is a distinct type from a raw string, mintable only
|
||||||
|
* via `parseBsn`, so holding one is proof it passed the **elfproef** (11-test) checksum, not
|
||||||
|
* just a 9-digit shape. Format/checksum only — identity is still faked in this POC (DigiD stub).
|
||||||
|
*/
|
||||||
|
export type Bsn = Brand<string, 'Bsn'>;
|
||||||
|
|
||||||
|
// Positional weights for the elfproef: 9·d1 + 8·d2 + … + 2·d8 − 1·d9 ≡ 0 (mod 11).
|
||||||
|
const WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1];
|
||||||
|
|
||||||
|
export function parseBsn(raw: string): Result<string, Bsn> {
|
||||||
|
const t = raw.trim();
|
||||||
|
if (!/^\d{9}$/.test(t)) {
|
||||||
|
return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
|
||||||
|
}
|
||||||
|
const sum = [...t].reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0);
|
||||||
|
if (t === '000000000' || sum % 11 !== 0) {
|
||||||
|
return err(
|
||||||
|
$localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ok(t as Bsn);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { maskBsn, maskTail } from './pii';
|
||||||
|
|
||||||
|
describe('pii maskers', () => {
|
||||||
|
it('maskBsn keeps the last 3 digits', () => {
|
||||||
|
expect(maskBsn('123456789')).toBe('******789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maskTail keeps the requested tail length', () => {
|
||||||
|
expect(maskTail('abcdef', 2)).toBe('****ef');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('masks the whole value when it is not longer than the kept tail', () => {
|
||||||
|
expect(maskBsn('12')).toBe('**');
|
||||||
|
expect(maskBsn('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* PII masking — pure functional core (WP-40). Data-minimisation helpers shared by the app
|
||||||
|
* (dev state panel, the masked-value atom, anywhere sensitive data is shown). No framework,
|
||||||
|
* no domain imports. The backend keeps a `MaskTail` twin in sync (see Program.cs).
|
||||||
|
*/
|
||||||
|
export const REDACTED = '‹redacted›';
|
||||||
|
|
||||||
|
/** Keep the last `keep` characters, mask the rest with `*`. */
|
||||||
|
export function maskTail(value: string, keep: number): string {
|
||||||
|
if (value.length <= keep) return '*'.repeat(value.length);
|
||||||
|
return '*'.repeat(value.length - keep) + value.slice(-keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mask a BSN / BIG-nummer for display: keep the last 3 digits, mask the rest. */
|
||||||
|
export function maskBsn(value: string): string {
|
||||||
|
return maskTail(value, 3);
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ import { Role } from '@shared/domain/role';
|
|||||||
import { ROLES, currentRole, setRole } from '@shared/infrastructure/role';
|
import { ROLES, currentRole, setRole } from '@shared/infrastructure/role';
|
||||||
import { Scenario, SCENARIOS, currentScenario, setScenario } from '@shared/infrastructure/scenario';
|
import { Scenario, SCENARIOS, currentScenario, setScenario } from '@shared/infrastructure/scenario';
|
||||||
import { stripDevParams } from '@shared/infrastructure/dev-params';
|
import { stripDevParams } from '@shared/infrastructure/dev-params';
|
||||||
import { maskBsn, redactProfile } from './mask';
|
import { maskBsn } from '@shared/kernel/pii';
|
||||||
|
import { redactProfile } from './mask';
|
||||||
|
|
||||||
// CIBG-GAP EXTENSION: n/a — devtool, no corresponding CIBG concept; deliberately
|
// CIBG-GAP EXTENSION: n/a — devtool, no corresponding CIBG concept; deliberately
|
||||||
// off-theme by design (see the ponytail note below), see cibg-gaps.mdx.
|
// off-theme by design (see the ponytail note below), see cibg-gaps.mdx.
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { maskBsn } from './mask';
|
|
||||||
|
|
||||||
describe('maskBsn', () => {
|
|
||||||
it('keeps the last 3 digits and masks the rest', () => {
|
|
||||||
expect(maskBsn('123456789')).toBe('******789');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles short and empty input without throwing', () => {
|
|
||||||
expect(maskBsn('12')).toBe('**');
|
|
||||||
expect(maskBsn('')).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +1,14 @@
|
|||||||
import { BigProfile } from '@registratie/domain/big-profile';
|
import { BigProfile } from '@registratie/domain/big-profile';
|
||||||
|
import { REDACTED, maskTail } from '@shared/kernel/pii';
|
||||||
const REDACTED = '‹redacted›';
|
|
||||||
|
|
||||||
/** Keep the last `keep` characters, mask the rest. */
|
|
||||||
function maskTail(value: string, keep: number): string {
|
|
||||||
if (value.length <= keep) return '*'.repeat(value.length);
|
|
||||||
return '*'.repeat(value.length - keep) + value.slice(-keep);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Redact a BSN for the dev state view: keep the last 3 digits, mask the rest. */
|
|
||||||
export function maskBsn(bsn: string): string {
|
|
||||||
return maskTail(bsn, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data minimisation for the dev "show the Model" panel: keep the structural /
|
* Data minimisation for the dev "show the Model" panel: keep the structural /
|
||||||
* decision-relevant fields (status, beroep, dates of registration) but redact
|
* decision-relevant fields (status, beroep, dates of registration) but redact
|
||||||
* direct personal identifiers (name, address, date of birth) and mask the BIG
|
* direct personal identifiers (name, address, date of birth) and mask the BIG
|
||||||
* number. The panel is for inspecting state SHAPE, never for reading PII.
|
* number. The panel is for inspecting state SHAPE, never for reading PII. The
|
||||||
|
* generic maskers live in `@shared/kernel/pii`; this stays here because it depends
|
||||||
|
* on the registratie `BigProfile` domain type (debug-state is the sanctioned
|
||||||
|
* cross-context devtool).
|
||||||
*/
|
*/
|
||||||
export function redactProfile(p: BigProfile): unknown {
|
export function redactProfile(p: BigProfile): unknown {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atom: a possibly-masked sensitive value (BSN, BIG-nummer, …) with an optional, audited
|
||||||
|
* reveal affordance (WP-40). The value arrives masked from the server (data-minimisation)
|
||||||
|
* and is swapped for the full value on reveal; the reveal button shows only when the value
|
||||||
|
* is still masked AND the caller says the principal may reveal it. Centralises the
|
||||||
|
* masked-detection that consumers used to sniff inline. The atom only emits `reveal`; the
|
||||||
|
* caller owns the step-up gesture + the audited fetch (see behandel-scherm).
|
||||||
|
*
|
||||||
|
* ponytail: masked-detection is the mask character (`*`) — a POC heuristic. A server-sent
|
||||||
|
* `masked` boolean would remove the sniff; wire it here without touching consumers.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-masked-value',
|
||||||
|
imports: [ButtonComponent],
|
||||||
|
template: `
|
||||||
|
<span class="value">{{ value() }}</span>
|
||||||
|
@if (canReveal() && masked()) {
|
||||||
|
<app-button variant="subtle" (click)="reveal.emit()">{{ revealLabel() }}</app-button>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class MaskedValueComponent {
|
||||||
|
value = input.required<string>();
|
||||||
|
canReveal = input(false);
|
||||||
|
revealLabel = input($localize`:@@maskedValue.reveal:Tonen`);
|
||||||
|
reveal = output<void>();
|
||||||
|
|
||||||
|
protected masked = computed(() => this.value().includes('*'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { MaskedValueComponent } from './masked-value.component';
|
||||||
|
|
||||||
|
const meta: Meta<MaskedValueComponent> = {
|
||||||
|
title: 'Design System/Atoms/Masked Value',
|
||||||
|
component: MaskedValueComponent,
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<MaskedValueComponent>;
|
||||||
|
|
||||||
|
/** Masked + the principal may reveal → the reveal button shows. */
|
||||||
|
export const RevealableMasked: Story = {
|
||||||
|
args: { value: '******601', canReveal: true, revealLabel: 'Toon BIG-nummer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Masked but no reveal right → just the masked value, no affordance. */
|
||||||
|
export const MaskedNoReveal: Story = {
|
||||||
|
args: { value: '******601', canReveal: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Already revealed (no mask character) → no reveal button even with the right. */
|
||||||
|
export const Revealed: Story = {
|
||||||
|
args: { value: '990000000012', canReveal: true },
|
||||||
|
};
|
||||||
@@ -6,8 +6,24 @@
|
|||||||
<source>Voer een geldig BSN van 9 cijfers in.</source>
|
<source>Voer een geldig BSN van 9 cijfers in.</source>
|
||||||
<target datatype="html">Enter a valid 9-digit BSN.</target>
|
<target datatype="html">Enter a valid 9-digit BSN.</target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/auth/infrastructure/digid.adapter.ts</context>
|
<context context-type="sourcefile">src/app/shared/kernel/bsn.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">15</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="validation.bsnElfproef" datatype="html">
|
||||||
|
<source>Dit is geen geldig BSN (klopt niet met de elfproef).</source>
|
||||||
|
<target datatype="html">This is not a valid BSN (fails the eleven-test checksum).</target>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/shared/kernel/bsn.ts</context>
|
||||||
|
<context context-type="linenumber">20</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="maskedValue.reveal" datatype="html">
|
||||||
|
<source>Tonen</source>
|
||||||
|
<target datatype="html">Show</target>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/shared/ui/masked-value/masked-value.component.ts</context>
|
||||||
|
<context context-type="linenumber">27</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="form.verplichteVelden" datatype="html">
|
<trans-unit id="form.verplichteVelden" datatype="html">
|
||||||
@@ -35,8 +51,8 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="login.bsnDescription" datatype="html">
|
<trans-unit id="login.bsnDescription" datatype="html">
|
||||||
<source>9 cijfers (demo: vul iets in)</source>
|
<source>9-cijferig BSN, elfproef-geldig (demo: 123456782)</source>
|
||||||
<target datatype="html">9 digits (demo: fill in anything)</target>
|
<target datatype="html">9-digit BSN, valid eleven-test checksum (demo: 123456782)</target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||||
<context context-type="linenumber">25,28</context>
|
<context context-type="linenumber">25,28</context>
|
||||||
|
|||||||
+41
-27
@@ -2,13 +2,6 @@
|
|||||||
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<file source-language="nl" datatype="plaintext" original="ng2.template">
|
<file source-language="nl" datatype="plaintext" original="ng2.template">
|
||||||
<body>
|
<body>
|
||||||
<trans-unit id="validation.bsn" datatype="html">
|
|
||||||
<source>Voer een geldig BSN van 9 cijfers in.</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/auth/infrastructure/digid.adapter.ts</context>
|
|
||||||
<context context-type="linenumber">13</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="form.verplichteVelden" datatype="html">
|
<trans-unit id="form.verplichteVelden" datatype="html">
|
||||||
<source>* verplichte velden</source>
|
<source>* verplichte velden</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
@@ -32,7 +25,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="login.bsnDescription" datatype="html">
|
<trans-unit id="login.bsnDescription" datatype="html">
|
||||||
<source>9 cijfers (demo: vul iets in)</source>
|
<source>9-cijferig BSN, elfproef-geldig (demo: 123456782)</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||||
<context context-type="linenumber">25,28</context>
|
<context context-type="linenumber">25,28</context>
|
||||||
@@ -322,81 +315,81 @@
|
|||||||
<source>Opnieuw indienen</source>
|
<source>Opnieuw indienen</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">221</context>
|
<context context-type="linenumber">222</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.submit" datatype="html">
|
<trans-unit id="brief.submit" datatype="html">
|
||||||
<source>Indienen ter beoordeling</source>
|
<source>Indienen ter beoordeling</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">222</context>
|
<context context-type="linenumber">223</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.step.beoordelen" datatype="html">
|
<trans-unit id="brief.step.beoordelen" datatype="html">
|
||||||
<source>Beoordelen</source>
|
<source>Beoordelen</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">226</context>
|
<context context-type="linenumber">227</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.step.opstellen" datatype="html">
|
<trans-unit id="brief.step.opstellen" datatype="html">
|
||||||
<source>Brief opstellen</source>
|
<source>Brief opstellen</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">227</context>
|
<context context-type="linenumber">228</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">231</context>
|
<context context-type="linenumber">232</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.step.indienen" datatype="html">
|
<trans-unit id="brief.step.indienen" datatype="html">
|
||||||
<source>Indienen</source>
|
<source>Indienen</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">228</context>
|
<context context-type="linenumber">229</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.process" datatype="html">
|
<trans-unit id="brief.process" datatype="html">
|
||||||
<source>Herregistratie behandelen</source>
|
<source>Herregistratie behandelen</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">230</context>
|
<context context-type="linenumber">231</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.case.heading" datatype="html">
|
<trans-unit id="brief.case.heading" datatype="html">
|
||||||
<source>Aanvraag herregistratie</source>
|
<source>Aanvraag herregistratie</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">232</context>
|
<context context-type="linenumber">233</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.case.big" datatype="html">
|
<trans-unit id="brief.case.big" datatype="html">
|
||||||
<source>BIG-nummer</source>
|
<source>BIG-nummer</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">233</context>
|
<context context-type="linenumber">234</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.case.reveal" datatype="html">
|
<trans-unit id="brief.case.reveal" datatype="html">
|
||||||
<source>Toon BIG-nummer</source>
|
<source>Toon BIG-nummer</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">234</context>
|
<context context-type="linenumber">235</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.case.revealConfirm" datatype="html">
|
<trans-unit id="brief.case.revealConfirm" datatype="html">
|
||||||
<source>Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan?</source>
|
<source>Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">236</context>
|
<context context-type="linenumber">237</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.preview.open" datatype="html">
|
<trans-unit id="brief.preview.open" datatype="html">
|
||||||
<source>Voorbeeld</source>
|
<source>Voorbeeld</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">238</context>
|
<context context-type="linenumber">239</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
|
||||||
@@ -407,21 +400,21 @@
|
|||||||
<source>Openen als document (PDF)</source>
|
<source>Openen als document (PDF)</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">240</context>
|
<context context-type="linenumber">241</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="common.close" datatype="html">
|
<trans-unit id="common.close" datatype="html">
|
||||||
<source>Sluiten</source>
|
<source>Sluiten</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">242</context>
|
<context context-type="linenumber">243</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.submitHint" datatype="html">
|
<trans-unit id="brief.submitHint" datatype="html">
|
||||||
<source>Vul eerst alle verplichte secties en los fouten op.</source>
|
<source>Vul eerst alle verplichte secties en los fouten op.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
|
||||||
<context context-type="linenumber">244</context>
|
<context context-type="linenumber">245</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="brief.besluit.positief" datatype="html">
|
<trans-unit id="brief.besluit.positief" datatype="html">
|
||||||
@@ -1070,11 +1063,11 @@
|
|||||||
<source>Maak een keuze.</source>
|
<source>Maak een keuze.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/herregistratie/domain/intake.machine.ts</context>
|
<context context-type="sourcefile">src/app/herregistratie/domain/intake.machine.ts</context>
|
||||||
<context context-type="linenumber">100</context>
|
<context context-type="linenumber">102</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/herregistratie/domain/intake.machine.ts</context>
|
<context context-type="sourcefile">src/app/herregistratie/domain/intake.machine.ts</context>
|
||||||
<context context-type="linenumber">112</context>
|
<context context-type="linenumber">114</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/registratie/domain/registratie-wizard.machine.ts</context>
|
<context context-type="sourcefile">src/app/registratie/domain/registratie-wizard.machine.ts</context>
|
||||||
@@ -1085,7 +1078,7 @@
|
|||||||
<source>Vul een land in.</source>
|
<source>Vul een land in.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/herregistratie/domain/intake.machine.ts</context>
|
<context context-type="sourcefile">src/app/herregistratie/domain/intake.machine.ts</context>
|
||||||
<context context-type="linenumber">103</context>
|
<context context-type="linenumber">105</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="herregWizard.processName" datatype="html">
|
<trans-unit id="herregWizard.processName" datatype="html">
|
||||||
@@ -1681,7 +1674,7 @@
|
|||||||
<source>Voer een geldige postcode in, bijv. 1234 AB.</source>
|
<source>Voer een geldige postcode in, bijv. 1234 AB.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/registratie/domain/value-objects/postcode.ts</context>
|
<context context-type="sourcefile">src/app/registratie/domain/value-objects/postcode.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">14</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="validation.telefoon" datatype="html">
|
<trans-unit id="validation.telefoon" datatype="html">
|
||||||
@@ -2614,6 +2607,20 @@
|
|||||||
<context context-type="linenumber">28</context>
|
<context context-type="linenumber">28</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="validation.bsn" datatype="html">
|
||||||
|
<source>Voer een geldig BSN van 9 cijfers in.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/shared/kernel/bsn.ts</context>
|
||||||
|
<context context-type="linenumber">17</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="validation.bsnElfproef" datatype="html">
|
||||||
|
<source>Dit is geen geldig BSN (klopt niet met de elfproef).</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/shared/kernel/bsn.ts</context>
|
||||||
|
<context context-type="linenumber">21</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="header.nav.huisstijl" datatype="html">
|
<trans-unit id="header.nav.huisstijl" datatype="html">
|
||||||
<source>Huisstijl</source>
|
<source>Huisstijl</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
@@ -2936,6 +2943,13 @@
|
|||||||
<context context-type="linenumber">31</context>
|
<context context-type="linenumber">31</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="maskedValue.reveal" datatype="html">
|
||||||
|
<source>Tonen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/shared/ui/masked-value/masked-value.component.ts</context>
|
||||||
|
<context context-type="linenumber">28</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="placeholderChip.auto" datatype="html">
|
<trans-unit id="placeholderChip.auto" datatype="html">
|
||||||
<source>wordt automatisch ingevuld</source>
|
<source>wordt automatisch ingevuld</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
|
|||||||
Reference in New Issue
Block a user