Compare commits
5
Commits
8c54ede6eb
...
92f825242a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92f825242a | ||
|
|
ee2413f2fe | ||
|
|
2bb16d1161 | ||
|
|
8cd925717f | ||
|
|
0f30143c5d |
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+260
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
|
||||
@@ -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/)`);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user