Compare commits

...
5 Commits
Author SHA1 Message Date
ehoandClaude Opus 4.8 92f825242a feat(i18n): CIBG "Taal instellen" language switcher
CI / frontend (push) Successful in 2m47s
CI / backend (push) Successful in 2m29s
CI / storybook-a11y (push) Successful in 8m12s
CI / e2e (push) Successful in 3m55s
CI / semgrep (push) Successful in 1m11s
CI / api-client-drift (push) Successful in 2m13s
Add a language switcher matching the CIBG Taal-instellen pattern: a <nav> region
(sr-only heading + aria-label) with an endonym link per locale (lang/hreflang, the
active one aria-current + non-link), mounted right after the skip link in the shell.
Compile-time $localize means each locale is its own bundle under /<locale>/, so the
switch is a full navigation to the sibling bundle — active locale read from the baked
base href. Pure localeLinks() (+spec) builds path-preserving targets. Since `ng serve`
serves nl-only at /, add `npm run serve:i18n` (localized build + a tiny static server
with per-locale SPA fallback) so the switch is demoable. +story.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 17:21:55 +02:00
ehoandClaude Opus 4.8 ee2413f2fe feat(brief): besluit guidance + read-only notice (role clarity)
Surface the besluit-driven assistance that was previously silent: a pure
besluitGuidance() (kern passage count + needs-reason flag) rendered as a hint below
the besluit panel in behandel-scherm (warning to pick a reden, else info on how many
standaardteksten were inserted). Add a read-only notice on letter-composer for a pure
viewer (no edit/approve/reject/send right, e.g. admin) so the read-only letter isn't
mistaken for a broken editor — the "reverted to a view of the letter" confusion was a
role issue (non-drafter sees the composer). +besluitGuidance spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 17:14:10 +02:00
ehoandClaude Opus 4.8 2bb16d1161 fix(dev): dev switcher reflects the current role/scenario in the dropdowns
The WP-33 panel's <select [value]> was applied before the @for options existed, so
the matching option was never selected (dropdown always showed the first item).
Move selection to [selected] on each <option>, and read role/scenario via getters
(currentRole()/currentScenario()) so the dropdowns reflect the active value whenever
the panel opens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:18:18 +02:00
ehoandClaude Opus 4.8 8cd925717f feat(beheer): admin audit view at /beheer/audit (finishes WP-42)
The WP-41 GET /admin/audit trail now has an FE view: a beheer audit page (domain
AuditEntry + adapter/parse + store) rendering the data-minimised trail as a read-only
table, capability-gated on cases:manage. Added to ADMIN_LINKS (header nav + dashboard
Beheer section) and to the role.interceptor ROLE_AWARE list so the admin-gated call
carries X-Role. Closes WP-42's audit half.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:05:36 +02:00
ehoandClaude Opus 4.8 0f30143c5d feat(privacy): WP-41 — persisted, queryable authz/PII-reveal audit
Persist the security-relevant events (authz denials + BIG-nummer reveal/step-up) into a
data-minimised EF table (AuthzAuditEntry: At/Action/Resource/Decision/Role/CorrelationId —
never a name/BSN/value), extending the DocumentStore AuditEntry pattern (migration AuthzAudit).
AuditAuthz now persists via AuthzAuditStore.Record alongside its log line. GET /admin/audit
(admin-gated by the existing CasesAdmin) returns the trail newest-first. +3 backend tests incl.
a schema-carries-no-PII reflection test. Typed client regenerated (audit() + AuthzAuditDto);
no FE consumer yet (a future audit view must add the ROLE_AWARE prefix). Finishes WP-42's audit half.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:53:33 +02:00
35 changed files with 5417 additions and 1372 deletions
@@ -79,6 +79,10 @@ public sealed record IntakeRequest(int Uren);
public sealed record HerregistratieRequest(int Uren, IReadOnlyList<DocumentRefDto>? Documents = null);
public sealed record ChangeRequestRequest(string Telefoon);
// Authz/PII-reveal audit row (WP-41) — data-minimised, no PII (see AuthzAuditEntry).
public sealed record AuthzAuditDto(
string At, string Action, string Resource, string Decision, string Role, string CorrelationId);
public sealed record ReferentieResponse(string Referentie);
// --- Applications (aanvragen): the system of record for the dashboard. ---
@@ -19,6 +19,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
{
public DbSet<StoredDocument> Documents => Set<StoredDocument>();
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
public DbSet<AuthzAuditEntry> AuthzAudit => Set<AuthzAuditEntry>();
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
public DbSet<OrgTemplateEntity> OrgTemplates => Set<OrgTemplateEntity>();
@@ -33,6 +34,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
e.Property(a => a.Id).ValueGeneratedOnAdd();
});
modelBuilder.Entity<AuthzAuditEntry>(e =>
{
e.HasKey(a => a.Id);
e.Property(a => a.Id).ValueGeneratedOnAdd();
});
modelBuilder.Entity<Aanvraag>(e =>
{
e.HasKey(a => a.Id);
@@ -0,0 +1,49 @@
namespace BigRegister.Api.Data;
/// <summary>
/// A persisted, DATA-MINIMISED authorization/PII-reveal audit entry (WP-41, PRD-0002 §8):
/// who (acting role, not identity), what action, on which resource ref, allow or deny, and
/// the correlation id — **never** a name, BSN, or the value that was (or wasn't) revealed.
/// Id is EF Core's auto-increment key (not positional), mirroring <see cref="AuditEntry"/>.
/// </summary>
public sealed record AuthzAuditEntry(
DateTimeOffset At,
string Action,
string Resource,
string Decision,
string Role,
string CorrelationId)
{
public long Id { get; init; }
}
/// <summary>
/// EF Core/SQLite-backed authz audit trail — the queryable twin of the log-only
/// <c>AuditAuthz</c> line. Same single-gate idiom as <see cref="DocumentStore"/>. Holds NO
/// PII by construction (see the entity); the schema test asserts it.
/// </summary>
public static class AuthzAuditStore
{
private static readonly object _gate = new();
public static void Record(string action, string resource, bool allowed, string role, string correlationId)
{
lock (_gate)
{
using var db = Db.Create();
db.AuthzAudit.Add(new AuthzAuditEntry(
DateTimeOffset.UtcNow, action, resource, allowed ? "allow" : "deny", role, correlationId));
db.SaveChanges();
}
}
/// Newest first. Ordered client-side: SQLite can't ORDER BY a DateTimeOffset (WP-36).
public static IReadOnlyList<AuthzAuditEntry> List()
{
lock (_gate)
{
using var db = Db.Create();
return db.AuthzAudit.ToList().OrderByDescending(a => a.At).ToList();
}
}
}
@@ -0,0 +1,260 @@
// <auto-generated />
using System;
using BigRegister.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260723134832_AuthzAudit")]
partial class AuthzAudit
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.AuthzAuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Decision")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuthzAudit");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("ArchivedHtml")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SentOrgTemplateVersion")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SubOrgId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
{
b.Property<string>("SubOrgId")
.HasColumnType("TEXT");
b.Property<string>("Draft")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("History")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PublishedVersion")
.HasColumnType("INTEGER");
b.HasKey("SubOrgId");
b.ToTable("OrgTemplates");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AuthzAudit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AuthzAudit",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
At = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Action = table.Column<string>(type: "TEXT", nullable: false),
Resource = table.Column<string>(type: "TEXT", nullable: false),
Decision = table.Column<string>(type: "TEXT", nullable: false),
Role = table.Column<string>(type: "TEXT", nullable: false),
CorrelationId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuthzAudit", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuthzAudit");
}
}
}
@@ -99,6 +99,40 @@ namespace BigRegister.Api.Data.Migrations
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.AuthzAuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Decision")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuthzAudit");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
+11
View File
@@ -330,6 +330,15 @@ api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ct
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status403Forbidden);
// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated
// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement.
api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
Results.Ok(AuthzAuditStore.List()
.Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId))
.ToList())))
.Produces<List<AuthzAuditDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
// tied to a specific brief's live status — see BriefDecisionsDto for that).
api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx))))
@@ -558,6 +567,8 @@ void AuditAuthz(HttpContext ctx, string action, string resource, bool allowed, P
app.Logger.LogInformation(
"authz action={Action} resource={Resource} decision={Decision} role={Role} correlationId={Cid}",
action, resource, allowed ? "allow" : "deny", principal.Role, cid);
// Persist the queryable, data-minimised trail (WP-41) alongside the log line.
AuthzAuditStore.Record(action, resource, allowed, principal.Role.ToString(), cid);
}
// Keep the last `keep` characters, mask the rest — mirrors the FE maskTail
+62
View File
@@ -801,6 +801,38 @@
}
}
},
"/api/v1/admin/audit": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AuthzAuditDto"
}
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/me": {
"get": {
"tags": [
@@ -1445,6 +1477,36 @@
},
"additionalProperties": false
},
"AuthzAuditDto": {
"type": "object",
"properties": {
"at": {
"type": "string",
"nullable": true
},
"action": {
"type": "string",
"nullable": true
},
"resource": {
"type": "string",
"nullable": true
},
"decision": {
"type": "string",
"nullable": true
},
"role": {
"type": "string",
"nullable": true
},
"correlationId": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"BriefDecisionsDto": {
"type": "object",
"properties": {
@@ -0,0 +1,55 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
/// WP-41: the persisted authz/PII-reveal audit trail is queryable, data-minimised (no PII).
public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private HttpRequestMessage Admin(HttpMethod method, string path)
{
var req = new HttpRequestMessage(method, path);
req.Headers.Add("X-Role", "admin");
return req;
}
private async Task<List<AuthzAuditDto>> AuditLog()
{
var res = await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/audit"));
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<List<AuthzAuditDto>>())!;
}
[Fact]
public async Task A_denied_admin_action_is_recorded()
{
// No X-Role → drafter → 403 on an admin endpoint → a deny entry.
Assert.Equal(HttpStatusCode.Forbidden, (await _client.GetAsync("/api/v1/admin/cases")).StatusCode);
Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "deny");
}
[Fact]
public async Task A_reveal_attempt_is_recorded()
{
// Drafter (capable role) without X-Step-Up → reveal denied → recorded.
var res = await _client.PostAsync("/api/v1/brief/reveal-bignummer", null);
Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode);
Assert.Contains(await AuditLog(), e => e.Action == "brief:reveal-bignummer");
}
[Fact]
public void The_audit_schema_carries_no_pii()
{
var names = typeof(AuthzAuditEntry).GetProperties().Select(p => p.Name).ToArray();
Assert.Equal(
new[] { "At", "Action", "Resource", "Decision", "Role", "CorrelationId", "Id" }.OrderBy(x => x),
names.OrderBy(x => x));
Assert.DoesNotContain(names, n => Regex.IsMatch(n, "naam|name|bsn|value|waarde", RegexOptions.IgnoreCase));
}
}
+48 -48
View File
@@ -43,54 +43,54 @@ WP-19's own file), so it's a separate manual/CI step, not chained into the other
Gates land before the work they cover; each lint rule lands in the same WP as the fixes
for its existing violations, so every WP ends green.
| WP | Title | Phase | Status |
| ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------- |
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo |
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
| [WP-32](WP-32-stamdata-undo.md) | Undo/redo in the stamdata editor | 7 · refinements | done |
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | 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-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 | done |
| [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 | partial |
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo |
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo |
| [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo |
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
| WP | Title | Phase | Status |
| ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------ |
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo |
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
| [WP-32](WP-32-stamdata-undo.md) | Undo/redo in the stamdata editor | 7 · refinements | done |
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | 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-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 | done |
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done |
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done |
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo |
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo |
| [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo |
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
@@ -1,10 +1,22 @@
# WP-41 — Persisted, queryable authz/PII-reveal audit
Status: todo
Status: done
Phase: 8 — platform/DX/showcase
Priority: P2
Depends on: WP-40
## Outcome
New data-minimised EF table `AuthzAuditEntry` (`Data/AuthzAuditStore.cs`, DbSet + key config in
`AppDbContext`, migration `AuthzAudit`): `At, Action, Resource, Decision, Role, CorrelationId` —
**never** a name/BSN/value. `AuditAuthz` now persists (via `AuthzAuditStore.Record`) alongside its
log line, so every authz denial + BIG-nummer reveal/step-up attempt is captured. `GET /admin/audit`
(admin-gated by the existing `CasesAdmin`/`cases:manage` — a dedicated `audit:read` cap is a later
refinement) returns the trail newest-first (client-side sort — SQLite can't ORDER BY DateTimeOffset).
+3 backend tests (deny recorded, reveal recorded, **schema-carries-no-PII** reflection test). Typed
client regenerated (`audit()` + `AuthzAuditDto`). No FE consumer yet — a future audit view (WP-42
finish) must add `/api/v1/admin/audit` to the `role.interceptor` ROLE_AWARE list or it silently 403s.
## Why
The security-relevant events (authz denials via `AuditAuthz`, BIG-nummer reveal, step-up) are
@@ -29,6 +41,6 @@ covers document lifecycle only. PRD-0002 §8 calls for a persisted authorization
## Acceptance criteria
- [ ] Denials, reveals, and step-up attempts land as rows with no PII/value fields.
- [ ] A test asserts the schema carries no name/bsn/value column.
- [ ] `dotnet test` + `npm run ci` green; api-client drift clean if endpoints added.
- [x] Denials, reveals, and step-up attempts land as rows with no PII/value fields.
- [x] A test asserts the schema carries no name/bsn/value column.
- [x] `dotnet test` (132) + `npm run ci` green; api-client drift clean after commit.
@@ -1,6 +1,15 @@
# WP-42 — Privacy & security showcase page
Status: partial — mask/parse showcase done; audit half pending WP-41
Status: done (optional Foundations MDX writeup left as a nice-to-have)
## Audit view (added after WP-41)
`/beheer/audit` — an admin page (`beheer/ui/audit.page.ts`) reading the WP-41 `GET /admin/audit`
trail through a `beheer` adapter/store (domain `AuditEntry` + trust-boundary parse), rendered as a
read-only table (time/action/resource/decision/role/correlation-id), capability-gated on
`cases:manage`. Added to `ADMIN_LINKS` (so it shows in the header nav + dashboard "Beheer" section)
and to the `role.interceptor` ROLE_AWARE list (else it silently 403s). This closes the audit half.
Phase: 8 — platform/DX/showcase
Priority: P2
Depends on: WP-40, WP-41
+3993 -1278
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -20,6 +20,7 @@
"dep:check": "depcruise src/app --config .dependency-cruiser.js",
"dep:graph": "bash scripts/dep-graph.sh",
"gen:snippets": "node scripts/gen-snippets.mjs",
"serve:i18n": "ng build --localize && node scripts/serve-i18n.mjs",
"ci": "bash scripts/ci-local.sh",
"e2e": "playwright test",
"extract-i18n": "ng extract-i18n --output-path src/locale"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
// Serve the localized production build so the language switcher actually works locally.
// `ng build --localize` emits dist/.../browser/{nl,en}/ (each with base href /nl/ or /en/).
// Plain `ng serve` (npm start) serves only nl at /, so switching 404s there — this static
// server serves both locale subdirs with per-locale SPA fallback (a miss under /<locale>/…
// serves that locale's index.html), so deep-link switches resolve. Demo only, not prod infra.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { join, extname, normalize } from 'node:path';
const ROOT = 'dist/atomic-design-poc/browser';
const PORT = 4300;
const MIME = {
'.html': 'text/html',
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.png': 'image/png',
};
const send = (res, status, body, type) => {
res.writeHead(status, { 'content-type': type });
res.end(body);
};
createServer(async (req, res) => {
const url = decodeURIComponent((req.url ?? '/').split('?')[0]);
// Landing at / has no locale bundle — redirect to Dutch.
if (url === '/') {
res.writeHead(302, { location: '/nl/' });
return res.end();
}
const rel = normalize(url).replace(/^(\.\.[/\\])+/, ''); // no path traversal
const locale = url.startsWith('/en/') ? 'en' : 'nl';
try {
const file = await readFile(join(ROOT, rel));
send(res, 200, file, MIME[extname(rel)] ?? 'application/octet-stream');
} catch {
// SPA fallback to the requested locale's index.html.
try {
const index = await readFile(join(ROOT, locale, 'index.html'));
send(res, 200, index, 'text/html');
} catch {
send(res, 404, 'Not found', 'text/plain');
}
}
}).listen(PORT, () => {
console.log(`Serving ${ROOT} at http://localhost:${PORT}/ (→ /nl/, /en/)`);
});
+7
View File
@@ -84,6 +84,13 @@ export const routes: Routes = [
loadComponent: () =>
import('@registratie/ui/admin-cases.page').then((m) => m.AdminCasesPage),
},
{
path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
},
{
path: 'concepts',
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
+32
View File
@@ -0,0 +1,32 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { AuditEntry } from '@beheer/domain/audit-entry';
import { AuditAdapter, parseAuditEntries } from '@beheer/infrastructure/audit.adapter';
type Err = Error | undefined;
/**
* Admin view of the persisted authz/PII-reveal audit trail (WP-41/42). One root singleton
* owning the list as a RemoteData signal, parsed at the trust boundary. Read-only.
*/
@Injectable({ providedIn: 'root' })
export class AuditStore {
private adapter = inject(AuditAdapter);
private state = signal<RemoteData<Err, AuditEntry[]>>({ tag: 'Loading' });
readonly entries = this.state.asReadonly();
async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseAuditEntries(await this.adapter.list());
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
this.state.set({ tag: 'Failure', error: e as Error });
}
}
}
+10
View File
@@ -0,0 +1,10 @@
/** One authz/PII-reveal audit row as the FE sees it (WP-41 backend → WP-42 view). Pure
type; data-minimised (no PII) by construction on the server. */
export interface AuditEntry {
at: string; // ISO timestamp
action: string;
resource: string;
decision: 'allow' | 'deny';
role: string;
correlationId: string;
}
@@ -0,0 +1,45 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { ApiClient } from '@shared/infrastructure/api-client';
import type { AuthzAuditDto } from '@shared/infrastructure/api-client';
import { AuditEntry } from '@beheer/domain/audit-entry';
/**
* Infrastructure adapter for the admin authz/PII-reveal audit trail (`GET /admin/audit`,
* WP-41). The single place the ApiClient lives for audit; the store parses at the boundary.
*/
@Injectable({ providedIn: 'root' })
export class AuditAdapter {
private client = inject(ApiClient);
list(): Promise<AuthzAuditDto[]> {
return this.client.audit();
}
}
/** Trust-boundary parse of the audit rows. */
export function parseAuditEntries(json: unknown): Result<string, AuditEntry[]> {
if (!Array.isArray(json)) return err('audit: not an array');
const out: AuditEntry[] = [];
for (const item of json) {
if (typeof item !== 'object' || item === null) return err('audit: row not an object');
const d = item as AuthzAuditDto;
if (
typeof d.at !== 'string' ||
typeof d.action !== 'string' ||
typeof d.resource !== 'string' ||
typeof d.role !== 'string' ||
typeof d.correlationId !== 'string'
)
return err('audit: missing fields');
out.push({
at: d.at,
action: d.action,
resource: d.resource,
decision: d.decision === 'allow' ? 'allow' : 'deny',
role: d.role,
correlationId: d.correlationId,
});
}
return ok(out);
}
+128
View File
@@ -0,0 +1,128 @@
import { Component, computed, effect, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { AuditStore } from '@beheer/application/audit.store';
/**
* Admin page: the persisted authz/PII-reveal audit trail (WP-41/42) — data-minimised, no PII.
* Deny-by-default capability gate (`cases:manage`, reused for admin audit read). Read-only table.
*/
@Component({
selector: 'app-audit-page',
imports: [PageShellComponent, AlertComponent, ButtonComponent, DatePipe, ...ASYNC],
styles: [
`
.scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--rhc-text-font-size-sm);
}
th,
td {
text-align: left;
padding: var(--rhc-space-max-sm) var(--rhc-space-max-md);
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200);
white-space: nowrap;
}
th {
font-weight: var(--rhc-text-font-weight-semi-bold);
}
.deny {
color: var(--rhc-color-rood-600, #a30000);
font-weight: var(--rhc-text-font-weight-semi-bold);
}
`,
],
template: `
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
@if (!access.ready()) {
<!-- wait for /me before deciding — avoids flashing the denial to an admin -->
} @else if (!canRead()) {
<app-alert type="error">{{ deniedText }}</app-alert>
} @else {
<app-async [data]="store.entries()">
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (entries().length === 0) {
<app-alert type="info">{{ emptyText }}</app-alert>
} @else {
<div class="scroll">
<table>
<thead>
<tr>
<th>{{ colTijd }}</th>
<th>{{ colActie }}</th>
<th>{{ colResource }}</th>
<th>{{ colBesluit }}</th>
<th>{{ colRol }}</th>
<th>{{ colCid }}</th>
</tr>
</thead>
<tbody>
@for (e of entries(); track e.at + e.action + e.correlationId) {
<tr>
<td>{{ e.at | date: 'short' }}</td>
<td>{{ e.action }}</td>
<td>{{ e.resource }}</td>
<td [class.deny]="e.decision === 'deny'">{{ e.decision }}</td>
<td>{{ e.role }}</td>
<td>{{ e.correlationId }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</ng-template>
</app-async>
}
</app-page-shell>
`,
})
export class AuditPage {
protected store = inject(AuditStore);
protected access = inject(AccessStore);
protected canRead = computed(() => this.access.can('cases:manage'));
protected entries = computed(() => {
const rd = this.store.entries();
return rd.tag === 'Success' ? rd.value : [];
});
protected heading = $localize`:@@audit.heading:Auditlog`;
protected intro = $localize`:@@audit.intro:Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens.`;
protected deniedText = $localize`:@@audit.denied:U hebt geen rechten om de auditlog te bekijken.`;
protected failedText = $localize`:@@audit.failed:De auditlog kon niet worden geladen.`;
protected emptyText = $localize`:@@audit.empty:Nog geen auditregels.`;
protected retryText = $localize`:@@audit.retry:Opnieuw proberen`;
protected colTijd = $localize`:@@audit.col.tijd:Tijd`;
protected colActie = $localize`:@@audit.col.actie:Actie`;
protected colResource = $localize`:@@audit.col.resource:Resource`;
protected colBesluit = $localize`:@@audit.col.besluit:Besluit`;
protected colRol = $localize`:@@audit.col.rol:Rol`;
protected colCid = $localize`:@@audit.col.cid:Correlatie-id`;
private loadRequested = false;
constructor() {
effect(() => {
if (this.canRead() && !this.loadRequested) {
this.loadRequested = true;
void this.store.load();
}
});
}
protected reload() {
void this.store.load();
}
}
+18 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { Besluit, LetterBlock, LibraryPassage } from './brief';
import { inferSelection, passagesForBesluit, redenenFor } from './besluit';
import { besluitGuidance, inferSelection, passagesForBesluit, redenenFor } from './besluit';
const block = (t: string): LibraryPassage['content'] => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
@@ -117,3 +117,20 @@ describe('inferSelection', () => {
expect(inferSelection(blocks, lib)).toEqual({ besluit: 'positief', reasons: [] });
});
});
describe('besluitGuidance', () => {
it('positief: counts inserted passages, no reden needed (positief has no redenen)', () => {
expect(besluitGuidance(lib, 'positief', [])).toEqual({ insertedCount: 2, needsReason: false });
});
it('negatief without a reden: flags that a reden must be chosen', () => {
expect(besluitGuidance(lib, 'negatief', [])).toEqual({ insertedCount: 2, needsReason: true });
});
it('negatief with a reden: no longer flags, and the reason passage is counted', () => {
expect(besluitGuidance(lib, 'negatief', ['onvoldoende_scholing'])).toEqual({
insertedCount: 3,
needsReason: false,
});
});
});
+20
View File
@@ -34,6 +34,26 @@ export interface Reden {
readonly label: string;
}
/** Visible assistance for the behandelaar on top of the silent auto-insert: how many kern
standaardteksten the current besluit+redenen produced, and whether a reden still needs
choosing (the besluit has reason-specific motivering passages but none is ticked). Pure
DATA — the component maps it to localized copy. */
export interface BesluitGuidance {
readonly insertedCount: number;
readonly needsReason: boolean;
}
export function besluitGuidance(
passages: readonly LibraryPassage[],
besluit: Besluit,
reasons: readonly string[],
): BesluitGuidance {
return {
insertedCount: passagesForBesluit(passages, besluit, reasons).length,
needsReason: redenenFor(passages, besluit).length > 0 && reasons.length === 0,
};
}
export function redenenFor(passages: readonly LibraryPassage[], besluit: Besluit): Reden[] {
const seen = new Set<string>();
const out: Reden[] = [];
@@ -3,9 +3,10 @@ import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
import { inferSelection } from '@brief/domain/besluit';
import { besluitGuidance, inferSelection } from '@brief/domain/besluit';
import { Diagnostic } from '@brief/domain/placeholders';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefMsg } from '@brief/domain/brief.machine';
@@ -27,6 +28,7 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
ButtonComponent,
HeadingComponent,
MaskedValueComponent,
AlertComponent,
StepperComponent,
LetterCanvasComponent,
DiagnosticsPanelComponent,
@@ -125,6 +127,14 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
(selectionChange)="onSelection($event)"
/>
@if (guidance(); as g) {
@if (g.needsReason) {
<app-alert type="warning">{{ needsReasonHint }}</app-alert>
} @else {
<app-alert type="info">{{ insertedHint(g.insertedCount) }}</app-alert>
}
}
<app-letter-editor [brief]="brief()" [placeholders]="menu()" (edit)="edit.emit($event)" />
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
@@ -198,6 +208,16 @@ export class BehandelSchermComponent {
return inferSelection(kern?.blocks ?? [], this.availablePassages());
});
/** Visible guidance for the current selection — null until a besluit is chosen (the
panel's own intro copy prompts that first step). */
protected guidance = computed(() => {
const s = this.selection();
return s.besluit ? besluitGuidance(this.availablePassages(), s.besluit, s.reasons) : null;
});
protected needsReasonHint = $localize`:@@brief.guidance.needsReason:Kies een reden, zodat de juiste motivering aan de brief wordt toegevoegd.`;
protected insertedHint = (n: number) =>
$localize`:@@brief.guidance.inserted:${n}:count: standaardtekst(en) toegevoegd op basis van het besluit. Vul aan met vrije tekst waar nodig.`;
// Same insert menu as the composer: only valid, fillable, non-deprecated fields.
protected menu = computed<PlaceholderOption[]>(() =>
this.brief()
@@ -80,6 +80,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<app-rejection-comments mode="show" [comments]="rejectComments()" />
}
@if (pureViewer()) {
<app-alert type="info">{{ readonlyNotice() }}</app-alert>
}
@if (showDiff() && removedCount() > 0) {
<app-alert type="info">{{ removedText() }}</app-alert>
}
@@ -162,6 +166,14 @@ export class LetterComposerComponent {
sentText = input($localize`:@@brief.sent:De brief is verzonden.`);
protected status = computed(() => this.brief().status.tag);
/** A pure viewer has no action on this letter (not the behandelaar, not an approver with
approve/reject/send) — e.g. an admin. Show a notice so the read-only letter isn't
mistaken for a broken editor. */
protected pureViewer = computed(() => !this.canApprove() && !this.canReject() && !this.canSend());
readonlyNotice = input(
$localize`:@@brief.readonlyNotice:Alleen-lezen weergave. De behandelaar stelt de brief op.`,
);
protected rejectComments = computed(() => {
const s = this.brief().status;
return s.tag === 'rejected' ? s.comments : '';
@@ -1120,6 +1120,48 @@ export class ApiClient {
return Promise.resolve<void>(null as any);
}
/**
* @return OK
*/
audit(): Promise<AuthzAuditDto[]> {
let url_ = this.baseUrl + "/api/v1/admin/audit";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processAudit(_response);
});
}
protected processAudit(response: Response): Promise<AuthzAuditDto[]> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AuthzAuditDto[];
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<AuthzAuditDto[]>(null as any);
}
/**
* @return OK
*/
@@ -1765,6 +1807,15 @@ export interface ApplicationSummaryDto {
owner?: string | undefined;
}
export interface AuthzAuditDto {
at?: string | undefined;
action?: string | undefined;
resource?: string | undefined;
decision?: string | undefined;
role?: string | undefined;
correlationId?: string | undefined;
}
export interface BriefDecisionsDto {
canEdit?: boolean;
canApprove?: boolean;
@@ -13,6 +13,7 @@ const ROLE_AWARE = [
'/api/v1/brief',
'/api/v1/admin/org-template',
'/api/v1/admin/cases',
'/api/v1/admin/audit',
'/api/v1/stamdata',
'/api/v1/me',
];
+6
View File
@@ -31,4 +31,10 @@ export const ADMIN_LINKS: readonly AdminLink[] = [
to: '/beheer/zaken',
cap: 'cases:manage',
},
{
label: $localize`:@@header.nav.audit:Auditlog`,
description: $localize`:@@admin.link.audit.desc:Toegangs- en inzagebeslissingen bekijken`,
to: '/beheer/audit',
cap: 'cases:manage',
},
];
@@ -0,0 +1,82 @@
import { Component, computed, input } from '@angular/core';
import { Locale, localeLinks } from './locale-links';
// CIBG-GAP EXTENSION: "Taal instellen" (designsystem.cibg.nl/componenten/taal-instellen) — no
// vendored Huisstijl class ships for it, so this is a small hand-rolled surface built from the
// token bridge. See cibg-gaps.mdx.
/**
* Organism: CIBG "Taal instellen" language switcher. A `<nav>` region (screenreader heading +
* aria-label) with one link per locale — the endonym, tagged with its `lang`/`hreflang`, the
* active one marked `aria-current` and rendered as text (not a link).
*
* Compile-time $localize means each locale is a separate bundle under `/<locale>/`, so switching
* is a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale
* is read from the baked `<base href>` (`/en/` → en, else nl) — the deployment truth, independent
* of the app-config `LOCALE_ID`. Only functional where both locale bundles are served (the
* localized build, e.g. `npm run serve:i18n`), not under plain `ng serve` (nl-only at `/`).
*/
@Component({
selector: 'app-language-switcher',
styles: [
`
nav {
display: flex;
justify-content: flex-end;
gap: var(--rhc-space-max-md);
padding: var(--rhc-space-max-sm) var(--rhc-space-max-2xl);
font-size: var(--rhc-text-font-size-sm);
}
a {
color: var(--rhc-color-hemelblauw-700);
}
[aria-current] {
font-weight: var(--rhc-text-font-weight-semi-bold);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
`,
],
template: `
<nav [attr.aria-label]="navLabel">
<h2 class="sr-only">{{ heading }}</h2>
@for (l of links(); track l.locale) {
@if (l.active) {
<span [attr.lang]="l.locale" aria-current="true">{{ l.label }}</span>
} @else {
<a [attr.lang]="l.locale" [attr.hreflang]="l.locale" [href]="l.href">{{ l.label }}</a>
}
}
</nav>
`,
})
export class LanguageSwitcherComponent {
/** Override the detected locale (stories/tests); the app detects it from the base href. */
activeLocale = input<Locale | undefined>(undefined);
private readonly detected: Locale = /\/en\//.test(document.baseURI) ? 'en' : 'nl';
private readonly loc =
typeof location !== 'undefined'
? location
: ({ pathname: '/', search: '', hash: '' } as Location);
protected links = computed(() =>
localeLinks(
this.loc.pathname,
this.activeLocale() ?? this.detected,
this.loc.search,
this.loc.hash,
),
);
protected navLabel = $localize`:@@lang.navLabel:Taal / Language`;
protected heading = $localize`:@@lang.heading:Kies een taal`;
}
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LanguageSwitcherComponent } from './language-switcher.component';
const meta: Meta<LanguageSwitcherComponent> = {
title: 'Design System/Organisms/Language Switcher',
component: LanguageSwitcherComponent,
};
export default meta;
type Story = StoryObj<LanguageSwitcherComponent>;
/** Dutch active (the source locale). */
export const NederlandsActive: Story = { args: { activeLocale: 'nl' } };
/** English active. */
export const EnglishActive: Story = { args: { activeLocale: 'en' } };
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { localeLinks } from './locale-links';
describe('localeLinks', () => {
it('preserves the route when already under a locale prefix, marks the active one', () => {
const links = localeLinks('/nl/dashboard', 'nl');
expect(links.map((l) => [l.locale, l.href, l.active])).toEqual([
['nl', '/nl/dashboard', true],
['en', '/en/dashboard', false],
]);
});
it('swaps the prefix and keeps a deep path (en active)', () => {
const links = localeLinks('/en/beheer/audit', 'en');
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/nl/beheer/audit');
expect(links.find((l) => l.locale === 'en')!.active).toBe(true);
});
it('handles an unprefixed dev path (served at /), keeps query + hash', () => {
const links = localeLinks('/registreren', 'nl', '?scenario=slow', '#top');
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/registreren?scenario=slow#top');
});
it('a bare locale root maps to the sibling root', () => {
expect(localeLinks('/nl', 'nl').find((l) => l.locale === 'en')!.href).toBe('/en/');
});
});
@@ -0,0 +1,37 @@
/** The app's two locales (Angular $localize: source `nl` + translation `en`). */
export type Locale = 'nl' | 'en';
export interface LocaleLink {
readonly locale: Locale;
/** Endonym — each language named in its own language (CIBG "Taal instellen"), not a code. */
readonly label: string;
/** Absolute path into the other locale's bundle, preserving the current route. */
readonly href: string;
readonly active: boolean;
}
const LOCALES: readonly { locale: Locale; label: string }[] = [
{ locale: 'nl', label: 'Nederlands' },
{ locale: 'en', label: 'English' },
];
/**
* Build the two language links for the switcher. Compile-time i18n serves each locale as its
* own bundle under `/<locale>/`, so switching is a full navigation to the sibling bundle at the
* same route. Strips any leading `/nl` or `/en` from the current path and re-prefixes the target
* locale, keeping query + hash. Pure — no DOM (the component passes `location.*` in).
*/
export function localeLinks(
pathname: string,
active: Locale,
search = '',
hash = '',
): LocaleLink[] {
const rest = pathname.replace(/^\/(nl|en)(?=\/|$)/, '') || '/';
return LOCALES.map(({ locale, label }) => ({
locale,
label,
href: `/${locale}${rest}${search}${hash}`,
active: locale === active,
}));
}
@@ -3,12 +3,19 @@ import { RouterOutlet } from '@angular/router';
import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component';
import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component';
import { DebugStateComponent } from '@shared/ui/debug-state/debug-state.component';
import { LanguageSwitcherComponent } from '@shared/layout/language-switcher/language-switcher.component';
/** Template: persistent app chrome. Header + footer mount once; only the routed
content inside <router-outlet> changes (and cross-fades — see styles.scss). */
@Component({
selector: 'app-shell',
imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent, DebugStateComponent],
imports: [
RouterOutlet,
SiteHeaderComponent,
SiteFooterComponent,
DebugStateComponent,
LanguageSwitcherComponent,
],
styles: [
`
:host {
@@ -46,6 +53,7 @@ import { DebugStateComponent } from '@shared/ui/debug-state/debug-state.componen
],
template: `
<a href="#main" class="skip" i18n="@@shell.skipLink">Naar de inhoud</a>
<app-language-switcher />
<div class="layout">
<app-site-header />
<main id="main" class="main">
@@ -95,17 +95,17 @@ import { redactProfile } from './mask';
<div class="switchers">
<label
>role
<select [value]="role" (change)="switchRole($any($event.target).value)">
<select (change)="switchRole($any($event.target).value)">
@for (r of roles; track r) {
<option [value]="r">{{ r }}</option>
<option [value]="r" [selected]="r === role">{{ r }}</option>
}
</select>
</label>
<label
>scenario
<select [value]="scenario" (change)="switchScenario($any($event.target).value)">
<select (change)="switchScenario($any($event.target).value)">
@for (s of scenarios; track s) {
<option [value]="s">{{ s }}</option>
<option [value]="s" [selected]="s === scenario">{{ s }}</option>
}
</select>
</label>
@@ -140,8 +140,14 @@ export class DebugStateComponent {
// read per-request in interceptors, so a reload re-runs them and re-fetches decisions.
protected readonly roles = ROLES;
protected readonly scenarios = SCENARIOS;
protected readonly role = currentRole();
protected readonly scenario = currentScenario();
// Getters so the dropdowns reflect the CURRENT value whenever the panel is opened
// (not just the value at component construction).
protected get role() {
return currentRole();
}
protected get scenario() {
return currentScenario();
}
switchRole(r: Role): void {
setRole(r);
+76
View File
@@ -290,6 +290,18 @@
<context context-type="linenumber">158</context>
</context-group>
</trans-unit>
<trans-unit id="brief.guidance.needsReason" datatype="html">
<source>Kies een reden, zodat de juiste motivering aan de brief wordt toegevoegd.</source>
<target datatype="html">Choose a reason so the correct justification is added to the letter.</target>
</trans-unit>
<trans-unit id="brief.guidance.inserted" datatype="html">
<source><x id="count" equiv-text="n"/> standaardtekst(en) toegevoegd op basis van het besluit. Vul aan met vrije tekst waar nodig.</source>
<target datatype="html"><x id="count" equiv-text="n"/> standard passage(s) added based on the decision. Add free text where needed.</target>
</trans-unit>
<trans-unit id="brief.readonlyNotice" datatype="html">
<source>Alleen-lezen weergave. De behandelaar stelt de brief op.</source>
<target datatype="html">Read-only view. The case handler composes the letter.</target>
</trans-unit>
<trans-unit id="brief.submit" datatype="html">
<source>Indienen ter beoordeling</source>
<target datatype="html">Submit for review</target>
@@ -2222,6 +2234,14 @@
<context context-type="linenumber">48,49</context>
</context-group>
</trans-unit>
<trans-unit id="lang.navLabel" datatype="html">
<source>Taal / Language</source>
<target datatype="html">Taal / Language</target>
</trans-unit>
<trans-unit id="lang.heading" datatype="html">
<source>Kies een taal</source>
<target datatype="html">Choose a language</target>
</trans-unit>
<trans-unit id="footer.tagline" datatype="html">
<source>De Rijksoverheid. Voor Nederland.</source>
<target datatype="html">The Government of the Netherlands.</target>
@@ -3658,6 +3678,62 @@
<source>Aanvragen</source>
<target datatype="html">Cases</target>
</trans-unit>
<trans-unit id="header.nav.audit" datatype="html">
<source>Auditlog</source>
<target datatype="html">Audit log</target>
</trans-unit>
<trans-unit id="admin.link.audit.desc" datatype="html">
<source>Toegangs- en inzagebeslissingen bekijken</source>
<target datatype="html">View access and disclosure decisions</target>
</trans-unit>
<trans-unit id="audit.heading" datatype="html">
<source>Auditlog</source>
<target datatype="html">Audit log</target>
</trans-unit>
<trans-unit id="audit.intro" datatype="html">
<source>Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens.</source>
<target datatype="html">Access and disclosure decisions (authorization and revealing masked data). Recorded without personal data.</target>
</trans-unit>
<trans-unit id="audit.denied" datatype="html">
<source>U hebt geen rechten om de auditlog te bekijken.</source>
<target datatype="html">You do not have permission to view the audit log.</target>
</trans-unit>
<trans-unit id="audit.failed" datatype="html">
<source>De auditlog kon niet worden geladen.</source>
<target datatype="html">The audit log could not be loaded.</target>
</trans-unit>
<trans-unit id="audit.empty" datatype="html">
<source>Nog geen auditregels.</source>
<target datatype="html">No audit entries yet.</target>
</trans-unit>
<trans-unit id="audit.retry" datatype="html">
<source>Opnieuw proberen</source>
<target datatype="html">Try again</target>
</trans-unit>
<trans-unit id="audit.col.tijd" datatype="html">
<source>Tijd</source>
<target datatype="html">Time</target>
</trans-unit>
<trans-unit id="audit.col.actie" datatype="html">
<source>Actie</source>
<target datatype="html">Action</target>
</trans-unit>
<trans-unit id="audit.col.resource" datatype="html">
<source>Resource</source>
<target datatype="html">Resource</target>
</trans-unit>
<trans-unit id="audit.col.besluit" datatype="html">
<source>Besluit</source>
<target datatype="html">Decision</target>
</trans-unit>
<trans-unit id="audit.col.rol" datatype="html">
<source>Rol</source>
<target datatype="html">Role</target>
</trans-unit>
<trans-unit id="audit.col.cid" datatype="html">
<source>Correlatie-id</source>
<target datatype="html">Correlation id</target>
</trans-unit>
<trans-unit id="dashboard.beheer" datatype="html">
<source>Beheer</source>
<target datatype="html">Administration</target>
+165 -32
View File
@@ -94,6 +94,90 @@
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="audit.heading" datatype="html">
<source>Auditlog</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">102</context>
</context-group>
</trans-unit>
<trans-unit id="audit.intro" datatype="html">
<source>Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">103</context>
</context-group>
</trans-unit>
<trans-unit id="audit.denied" datatype="html">
<source>U hebt geen rechten om de auditlog te bekijken.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">104</context>
</context-group>
</trans-unit>
<trans-unit id="audit.failed" datatype="html">
<source>De auditlog kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="audit.empty" datatype="html">
<source>Nog geen auditregels.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="audit.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">107</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.tijd" datatype="html">
<source>Tijd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">108</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.actie" datatype="html">
<source>Actie</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">109</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.resource" datatype="html">
<source>Resource</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">110</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.besluit" datatype="html">
<source>Besluit</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">111</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.rol" datatype="html">
<source>Rol</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">112</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.cid" datatype="html">
<source>Correlatie-id</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">113</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.added" datatype="html">
<source>toegevoegd</source>
<context-group purpose="location">
@@ -311,110 +395,124 @@
<context context-type="linenumber">7</context>
</context-group>
</trans-unit>
<trans-unit id="brief.guidance.needsReason" datatype="html">
<source>Kies een reden, zodat de juiste motivering aan de brief wordt toegevoegd.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">217</context>
</context-group>
</trans-unit>
<trans-unit id="brief.guidance.inserted" datatype="html">
<source><x id="count" equiv-text="n"/> standaardtekst(en) toegevoegd op basis van het besluit. Vul aan met vrije tekst waar nodig.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">219</context>
</context-group>
</trans-unit>
<trans-unit id="brief.resubmit" datatype="html">
<source>Opnieuw indienen</source>
<context-group purpose="location">
<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">242</context>
</context-group>
</trans-unit>
<trans-unit id="brief.submit" datatype="html">
<source>Indienen ter beoordeling</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">223</context>
<context context-type="linenumber">243</context>
</context-group>
</trans-unit>
<trans-unit id="brief.step.beoordelen" datatype="html">
<source>Beoordelen</source>
<context-group purpose="location">
<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">247</context>
</context-group>
</trans-unit>
<trans-unit id="brief.step.opstellen" datatype="html">
<source>Brief opstellen</source>
<context-group purpose="location">
<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">248</context>
</context-group>
<context-group purpose="location">
<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">252</context>
</context-group>
</trans-unit>
<trans-unit id="brief.step.indienen" datatype="html">
<source>Indienen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">229</context>
<context context-type="linenumber">249</context>
</context-group>
</trans-unit>
<trans-unit id="brief.process" datatype="html">
<source>Herregistratie behandelen</source>
<context-group purpose="location">
<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">251</context>
</context-group>
</trans-unit>
<trans-unit id="brief.case.heading" datatype="html">
<source>Aanvraag herregistratie</source>
<context-group purpose="location">
<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">253</context>
</context-group>
</trans-unit>
<trans-unit id="brief.case.big" datatype="html">
<source>BIG-nummer</source>
<context-group purpose="location">
<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">254</context>
</context-group>
</trans-unit>
<trans-unit id="brief.case.reveal" datatype="html">
<source>Toon BIG-nummer</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">235</context>
<context context-type="linenumber">255</context>
</context-group>
</trans-unit>
<trans-unit id="brief.case.revealConfirm" datatype="html">
<source>Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">237</context>
<context context-type="linenumber">257</context>
</context-group>
</trans-unit>
<trans-unit id="brief.preview.open" datatype="html">
<source>Voorbeeld</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">239</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">150</context>
<context context-type="linenumber">154</context>
</context-group>
</trans-unit>
<trans-unit id="brief.preview.openDocument" datatype="html">
<source>Openen als document (PDF)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">241</context>
<context context-type="linenumber">261</context>
</context-group>
</trans-unit>
<trans-unit id="common.close" datatype="html">
<source>Sluiten</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">243</context>
<context context-type="linenumber">263</context>
</context-group>
</trans-unit>
<trans-unit id="brief.submitHint" datatype="html">
<source>Vul eerst alle verplichte secties en los fouten op.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">245</context>
<context context-type="linenumber">265</context>
</context-group>
</trans-unit>
<trans-unit id="brief.besluit.positief" datatype="html">
@@ -737,91 +835,98 @@
<source>Brief aan de zorgverlener</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">149</context>
<context context-type="linenumber">153</context>
</context-group>
</trans-unit>
<trans-unit id="brief.diff.show" datatype="html">
<source>Toon wijzigingen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">151</context>
<context context-type="linenumber">155</context>
</context-group>
</trans-unit>
<trans-unit id="brief.diff.hide" datatype="html">
<source>Verberg wijzigingen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">152</context>
<context context-type="linenumber">156</context>
</context-group>
</trans-unit>
<trans-unit id="brief.diff.removed" datatype="html">
<source><x id="count" equiv-text="this.removedCount()"/> blok(ken) verwijderd sinds afwijzing.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">155</context>
<context context-type="linenumber">159</context>
</context-group>
</trans-unit>
<trans-unit id="brief.approve" datatype="html">
<source>Goedkeuren</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">157</context>
<context context-type="linenumber">161</context>
</context-group>
</trans-unit>
<trans-unit id="brief.send" datatype="html">
<source>Versturen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">158</context>
<context context-type="linenumber">162</context>
</context-group>
</trans-unit>
<trans-unit id="brief.awaiting" datatype="html">
<source>De brief wacht op beoordeling door een collega.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">160</context>
<context context-type="linenumber">164</context>
</context-group>
</trans-unit>
<trans-unit id="brief.sent" datatype="html">
<source>De brief is verzonden.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">162</context>
<context context-type="linenumber">166</context>
</context-group>
</trans-unit>
<trans-unit id="brief.readonlyNotice" datatype="html">
<source>Alleen-lezen weergave. De behandelaar stelt de brief op.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">175</context>
</context-group>
</trans-unit>
<trans-unit id="brief.status.draft" datatype="html">
<source>Concept</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">173</context>
<context context-type="linenumber">185</context>
</context-group>
</trans-unit>
<trans-unit id="brief.status.submitted" datatype="html">
<source>Ter beoordeling</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">175</context>
<context context-type="linenumber">187</context>
</context-group>
</trans-unit>
<trans-unit id="brief.status.approved" datatype="html">
<source>Goedgekeurd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">177</context>
<context context-type="linenumber">189</context>
</context-group>
</trans-unit>
<trans-unit id="brief.status.rejected" datatype="html">
<source>Afgewezen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">179</context>
<context context-type="linenumber">191</context>
</context-group>
</trans-unit>
<trans-unit id="brief.status.sent" datatype="html">
<source>Verzonden</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
<context context-type="linenumber">181</context>
<context context-type="linenumber">193</context>
</context-group>
</trans-unit>
<trans-unit id="brief.section.required" datatype="html">
@@ -2611,14 +2716,14 @@
<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 context-type="linenumber">18</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 context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.huisstijl" datatype="html">
@@ -2663,6 +2768,20 @@
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.audit" datatype="html">
<source>Auditlog</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/admin-links.ts</context>
<context context-type="linenumber">35</context>
</context-group>
</trans-unit>
<trans-unit id="admin.link.audit.desc" datatype="html">
<source>Toegangs- en inzagebeslissingen bekijken</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/admin-links.ts</context>
<context context-type="linenumber">36</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.dashboard" datatype="html">
<source>Mijn overzicht</source>
<context-group purpose="location">
@@ -2719,6 +2838,20 @@
<context context-type="linenumber">28,29</context>
</context-group>
</trans-unit>
<trans-unit id="lang.navLabel" datatype="html">
<source>Taal / Language</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/language-switcher/language-switcher.component.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="lang.heading" datatype="html">
<source>Kies een taal</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/language-switcher/language-switcher.component.ts</context>
<context context-type="linenumber">73</context>
</context-group>
</trans-unit>
<trans-unit id="pageShell.backLabel" datatype="html">
<source>Terug naar overzicht</source>
<context-group purpose="location">
@@ -2730,7 +2863,7 @@
<source>Naar de inhoud</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/shell/shell.component.ts</context>
<context context-type="linenumber">48,49</context>
<context context-type="linenumber">55,56</context>
</context-group>
</trans-unit>
<trans-unit id="footer.tagline" datatype="html">