Compare commits
4
Commits
deb5d77e04
...
fbc4bf51d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc4bf51d0 | ||
|
|
67802c68b4 | ||
|
|
ed264be714 | ||
|
|
c00e607b8f |
+1
-1
@@ -18,7 +18,7 @@
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"i18n": {
|
||||
"sourceLocale": "nl",
|
||||
"sourceLocale": { "code": "nl", "subPath": "" },
|
||||
"locales": {
|
||||
"en": {
|
||||
"translation": "src/locale/messages.en.xlf"
|
||||
|
||||
@@ -83,6 +83,10 @@ public sealed record ChangeRequestRequest(string Telefoon);
|
||||
public sealed record AuthzAuditDto(
|
||||
string At, string Action, string Resource, string Decision, string Role, string CorrelationId);
|
||||
|
||||
// Feature flags (WP-47): the resolved flag set + the admin toggle body.
|
||||
public sealed record FeatureFlagDto(string Key, string Description, bool Enabled);
|
||||
public sealed record SetFeatureFlagRequest(bool Enabled);
|
||||
|
||||
public sealed record ReferentieResponse(string Referentie);
|
||||
|
||||
// --- Applications (aanvragen): the system of record for the dashboard. ---
|
||||
|
||||
@@ -20,6 +20,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<FeatureFlagEntity> FeatureFlags => Set<FeatureFlagEntity>();
|
||||
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
|
||||
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
|
||||
public DbSet<OrgTemplateEntity> OrgTemplates => Set<OrgTemplateEntity>();
|
||||
@@ -40,6 +41,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
e.Property(a => a.Id).ValueGeneratedOnAdd();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<FeatureFlagEntity>().HasKey(f => f.Key);
|
||||
|
||||
modelBuilder.Entity<Aanvraag>(e =>
|
||||
{
|
||||
e.HasKey(a => a.Id);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using BigRegister.Domain.Features;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// One persisted runtime override for a feature flag (only stored once toggled; otherwise the
|
||||
/// catalog default applies). Key = the flag key from the code catalog (FeatureFlags.Catalog).
|
||||
public sealed class FeatureFlagEntity
|
||||
{
|
||||
public required string Key { get; init; }
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// A flag resolved for a consumer: catalog default overlaid with any stored override.
|
||||
public sealed record ResolvedFlag(string Key, string Description, bool Enabled);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime feature-flag state (WP-47). SQLite-backed like <see cref="OrgTemplateStore"/>, same
|
||||
/// single-gate idiom. The CATALOG (which flags exist + their defaults) is code
|
||||
/// (<see cref="FeatureFlags"/>); this store only holds the admin's on/off overrides. An unknown
|
||||
/// key is never writable/enabled — the code catalog is the authority.
|
||||
/// </summary>
|
||||
public static class FeatureFlagStore
|
||||
{
|
||||
private static readonly object _gate = new();
|
||||
|
||||
/// Catalog defaults overlaid with stored overrides — the whole flag set for the admin UI + FE.
|
||||
public static IReadOnlyList<ResolvedFlag> All()
|
||||
{
|
||||
Dictionary<string, bool> overrides;
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
overrides = db.FeatureFlags.ToDictionary(f => f.Key, f => f.Enabled);
|
||||
}
|
||||
return FeatureFlags.Catalog
|
||||
.Select(d => new ResolvedFlag(d.Key, d.Description,
|
||||
overrides.TryGetValue(d.Key, out var e) ? e : d.DefaultEnabled))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// Server-side enforcement helper. Unknown key → false (fail closed).
|
||||
public static bool IsEnabled(string key)
|
||||
{
|
||||
var def = FeatureFlags.Catalog.FirstOrDefault(d => d.Key == key);
|
||||
if (def is null) return false;
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.FeatureFlags.Find(key)?.Enabled ?? def.DefaultEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set an override for a KNOWN flag; returns false for an unknown key (caller → 404).
|
||||
public static bool Set(string key, bool enabled)
|
||||
{
|
||||
if (!FeatureFlags.Catalog.Any(d => d.Key == key)) return false;
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
var row = db.FeatureFlags.Find(key);
|
||||
if (row is null) db.FeatureFlags.Add(new FeatureFlagEntity { Key = key, Enabled = enabled });
|
||||
else row.Enabled = enabled;
|
||||
db.SaveChanges();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
// <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("20260723202131_FeatureFlags")]
|
||||
partial class FeatureFlags
|
||||
{
|
||||
/// <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.FeatureFlagEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("FeatureFlags");
|
||||
});
|
||||
|
||||
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,33 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BigRegister.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeatureFlags : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeatureFlags",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Enabled = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FeatureFlags", x => x.Key);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeatureFlags");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,19 @@ namespace BigRegister.Api.Data.Migrations
|
||||
b.ToTable("Briefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.FeatureFlagEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("FeatureFlags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("SubOrgId")
|
||||
|
||||
@@ -43,7 +43,7 @@ public static class Authz
|
||||
public static IReadOnlyList<string> RoleCapabilities(Principal principal) => principal.Role switch
|
||||
{
|
||||
PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" },
|
||||
PrincipalRole.Admin => new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage" },
|
||||
PrincipalRole.Admin => new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage", "flags:manage" },
|
||||
_ => Array.Empty<string>(),
|
||||
};
|
||||
|
||||
@@ -74,6 +74,9 @@ public static class Authz
|
||||
/// list + admin delete.
|
||||
public static bool CanManageCases(Principal principal) => principal.Role == PrincipalRole.Admin;
|
||||
|
||||
/// Feature-flag management (WP-47): admin-only, resource-independent — role IS the decision.
|
||||
public static bool CanManageFeatureFlags(Principal principal) => principal.Role == PrincipalRole.Admin;
|
||||
|
||||
/// Field-level PII (PRD-0002 §5c, phase P2): the case screen's BIG-nummer ships
|
||||
/// masked by default; only the behandelaar (Drafter) composing the case — the actor
|
||||
/// whose behandel-scherm shows the field — may reveal it. Role-based in the POC; a
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace BigRegister.Domain.Features;
|
||||
|
||||
/// A known feature flag — DECLARED in code (this catalog, build-validated), TOGGLED at runtime
|
||||
/// (on/off state persisted in SQLite by FeatureFlagStore). Same split as stamdata (catalog is
|
||||
/// config-as-code) × org-templates (runtime state in the DB): what flags exist is code; whether
|
||||
/// they're on is operational config an admin flips.
|
||||
public sealed record FeatureFlagDef(string Key, string Description, bool DefaultEnabled);
|
||||
|
||||
public static class FeatureFlags
|
||||
{
|
||||
/// Whether self-service registration (inschrijving) is open. When off, the FE hides the
|
||||
/// "Inschrijven" action and POST /applications for a `registratie` is refused (server-enforced).
|
||||
public const string InschrijvingOpen = "inschrijving-open";
|
||||
|
||||
public static readonly IReadOnlyList<FeatureFlagDef> Catalog = new[]
|
||||
{
|
||||
new FeatureFlagDef(
|
||||
InschrijvingOpen,
|
||||
"Zelf-inschrijving in het BIG-register is opengesteld.",
|
||||
DefaultEnabled: true),
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Features;
|
||||
using BigRegister.Domain.Intake;
|
||||
using BigRegister.Domain.Letters;
|
||||
using BigRegister.Domain.Registrations;
|
||||
@@ -244,6 +245,9 @@ api.MapGet("/applications/{id}", (string id) =>
|
||||
|
||||
api.MapPost("/applications", (CreateApplicationRequest req) =>
|
||||
{
|
||||
// Feature flag (WP-47): self-service registration can be closed by an admin.
|
||||
if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen))
|
||||
return Results.Problem(detail: "Inschrijving is momenteel gesloten.", statusCode: StatusCodes.Status403Forbidden);
|
||||
var a = ApplicationStore.CreateConcept(req.Type, DocumentStore.DemoOwner);
|
||||
if (a is null)
|
||||
return Results.Problem(
|
||||
@@ -344,6 +348,18 @@ api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||
api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx))))
|
||||
.Produces<MeDto>();
|
||||
|
||||
// Feature flags (WP-47). GET is readable by any principal (it drives FE gating); the toggle is
|
||||
// admin-only. Catalog is code; state is the runtime override in SQLite.
|
||||
api.MapGet("/flags", () =>
|
||||
Results.Ok(FeatureFlagStore.All().Select(f => new FeatureFlagDto(f.Key, f.Description, f.Enabled)).ToList()))
|
||||
.Produces<List<FeatureFlagDto>>();
|
||||
|
||||
api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) => FlagsAdmin(ctx, () =>
|
||||
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound()))
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// --- Brief (letter composition). One demo brief per owner; the server owns the
|
||||
// status machine + authorization (Authz, PRD-0002 phase P1). Principal is a
|
||||
// dev-only stand-in via X-Role (mirrors the X-Admin seam and the FE ?role=
|
||||
@@ -556,6 +572,16 @@ IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47).
|
||||
IResult FlagsAdmin(HttpContext ctx, Func<IResult> action)
|
||||
{
|
||||
var principal = Authz.ResolvePrincipal(ctx);
|
||||
if (Authz.CanManageFeatureFlags(principal)) return action();
|
||||
AuditAuthz(ctx, "flags:manage", "feature-flags", false, principal);
|
||||
return Results.Problem(detail: "Alleen een beheerder mag functievlaggen beheren.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
static StamdataColumnDto ToColumnDto(StamdataColumn c) => new(c.Name, c.Type, c.IsKey, c.Options);
|
||||
|
||||
// Authorization audit (PRD-0002 §8): access-relevant decisions recorded with NO PII —
|
||||
|
||||
@@ -852,6 +852,73 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/flags": {
|
||||
"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/FeatureFlagDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/flags/{key}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetFeatureFlagRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/brief": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1830,6 +1897,23 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"FeatureFlagDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HerregistratieDecisionsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2396,6 +2480,15 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SetFeatureFlagRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"StamdataColumnDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Features;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// WP-47: runtime feature flags — catalog in code, admin-toggled, server-enforced.
|
||||
public class FeatureFlagTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private HttpRequestMessage Admin(HttpMethod method, string path, object? body = null)
|
||||
{
|
||||
var req = new HttpRequestMessage(method, path) { Headers = { { "X-Role", "admin" } } };
|
||||
if (body is not null) req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_catalog_has_unique_keys()
|
||||
{
|
||||
var keys = FeatureFlags.Catalog.Select(f => f.Key).ToList();
|
||||
Assert.Equal(keys.Count, keys.Distinct().Count());
|
||||
Assert.All(FeatureFlags.Catalog, f => Assert.False(string.IsNullOrWhiteSpace(f.Key)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_flags_returns_the_catalog()
|
||||
{
|
||||
var flags = await _client.GetFromJsonAsync<List<FeatureFlagDto>>("/api/v1/flags");
|
||||
Assert.Contains(flags!, f => f.Key == FeatureFlags.InschrijvingOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Toggling_is_admin_only_and_an_unknown_key_is_404()
|
||||
{
|
||||
// Non-admin (no X-Role → drafter) may not toggle.
|
||||
var denied = await _client.PutAsJsonAsync(
|
||||
$"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = false });
|
||||
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
|
||||
|
||||
// Admin, unknown flag → 404.
|
||||
var unknown = await _client.SendAsync(Admin(HttpMethod.Put, "/api/v1/admin/flags/does-not-exist", new { enabled = true }));
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Closing_inschrijving_blocks_a_registratie_then_reopening_allows_it()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Off → POST /applications for a registratie is refused.
|
||||
(await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = false })))
|
||||
.EnsureSuccessStatusCode();
|
||||
var blocked = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
|
||||
Assert.Equal(HttpStatusCode.Forbidden, blocked.StatusCode);
|
||||
|
||||
// On → allowed again.
|
||||
(await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = true })))
|
||||
.EnsureSuccessStatusCode();
|
||||
var ok = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
|
||||
Assert.Equal(HttpStatusCode.Created, ok.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Leave the flag on (shared DB across this class).
|
||||
await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,9 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
|
||||
{
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/me", role: "admin"));
|
||||
var me = await res.Content.ReadFromJsonAsync<MeDto>();
|
||||
Assert.Equal(new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage" }, me!.Capabilities);
|
||||
Assert.Equal(
|
||||
new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage", "flags:manage" },
|
||||
me!.Capabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -11,14 +11,31 @@ namespace BigRegister.Tests;
|
||||
/// </summary>
|
||||
public class StamdataValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Every_seeded_diploma_program_maps_to_a_known_profession()
|
||||
/// Declared references INTO stamdata keys — the FK-like invariants the build gate enforces
|
||||
/// (WP-48). Add an entry when a consumer starts depending on a stamdata key; the gate then
|
||||
/// fails a delete/rename/expire that orphans it. Resolvers use the "valid today" views, so
|
||||
/// expiring a row (geldigTot in the past) that current data still references also fails —
|
||||
/// which steers the editor toward closing validity only once nothing current relies on it.
|
||||
private sealed record StamdataRef(string Description, IEnumerable<string> Keys, Func<string, bool> Resolves);
|
||||
|
||||
private static readonly IReadOnlyList<StamdataRef> References = new[]
|
||||
{
|
||||
// The dangling-reference guard: a seed program with no entry in Professions would
|
||||
// silently render "Onbekend" to the user. Fail the build instead.
|
||||
foreach (var d in SeedData.Diplomas)
|
||||
Assert.True(DiplomaRules.ProfessionFor(d) != "Onbekend",
|
||||
$"Diploma program '{d.Opleiding}' has no profession in Stamdata.Professions.");
|
||||
new StamdataRef(
|
||||
"Diploma.Opleiding → professions.program (valid today)",
|
||||
SeedData.Diplomas.Select(d => d.Opleiding),
|
||||
key => Professions.ByProgram.ContainsKey(key)),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Every_declared_reference_into_stamdata_resolves()
|
||||
{
|
||||
// The dangling-reference guard (generalized): a referenced key with no (currently valid)
|
||||
// stamdata row would silently break its consumer. Fail the build instead of prod.
|
||||
foreach (var r in References)
|
||||
foreach (var key in r.Keys)
|
||||
Assert.True(r.Resolves(key),
|
||||
$"Dangling stamdata reference [{r.Description}]: '{key}' no longer resolves — " +
|
||||
"deleting or expiring the referenced row would break it.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+6
-5
@@ -1,8 +1,9 @@
|
||||
# ponytail: dev-server images (not multi-stage prod builds) — this is a demo.
|
||||
# `docker compose up` → app at http://localhost:4200 (LOCALIZED: /nl/ + /en/, the header
|
||||
# language switcher works), Swagger at http://localhost:5000/swagger. The web container does a
|
||||
# one-time `ng build --localize` then serves both locale bundles statically (with /api proxied);
|
||||
# for a fast HMR loop use `npm start` locally instead (nl-only at /).
|
||||
# `docker compose up` → app at http://localhost:4200 (LOCALIZED: nl at /, en at /en/, the header
|
||||
# language switcher works, and the dev `⚙ state` panel stays visible), Swagger at :5000/swagger.
|
||||
# The web container does a one-time `ng build --configuration development --localize` (development
|
||||
# config keeps isDevMode()=true so the dev tools render) then serves both locale bundles
|
||||
# statically (with /api proxied); for a fast HMR loop use `npm start` locally instead (nl at /).
|
||||
services:
|
||||
api:
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
@@ -37,7 +38,7 @@ services:
|
||||
# once (`ng build --localize`) then serves them statically via scripts/serve-i18n.mjs
|
||||
# (per-locale SPA fallback + /api reverse-proxy → the api container), so the language
|
||||
# switcher actually switches. ponytail: `--no-fund --loglevel=error` silences npm 11 noise.
|
||||
command: sh -c "npm ci --no-fund --loglevel=error && npx ng build --localize && node scripts/serve-i18n.mjs"
|
||||
command: sh -c "npm ci --no-fund --loglevel=error && npx ng build --configuration development --localize && node scripts/serve-i18n.mjs"
|
||||
environment:
|
||||
- PORT=4200
|
||||
- API_PROXY_TARGET=http://api:5000
|
||||
|
||||
@@ -91,6 +91,8 @@ for its existing violations, so every WP ends green.
|
||||
| [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-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
|
||||
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 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
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# WP-47 — Runtime feature flags (catalog-in-code, admin-toggled)
|
||||
|
||||
Status: done
|
||||
Phase: 8 — platform/DX/showcase
|
||||
|
||||
## Why
|
||||
|
||||
Ops needs to turn features on/off at runtime without a deploy. Mirrors the two house templates: the
|
||||
capability spine (server-resolved, FE reads) and the org-template runtime-SQLite config (admin edits
|
||||
at runtime). Per ADR-0004 the **catalog** (which flags exist + defaults) is config-as-code; only the
|
||||
**on/off state** is runtime.
|
||||
|
||||
## Decisions (locked with the user)
|
||||
|
||||
- Catalog in code (typed, build-validated); on/off state in SQLite; admin toggles at runtime.
|
||||
- **FE + backend enforcement** — the FE hides the surface AND the server enforces (a flag can guard
|
||||
a real feature, not just UI).
|
||||
|
||||
## Outcome
|
||||
|
||||
- Backend: `Domain/Features/FeatureFlags.cs` (catalog: one flag `inschrijving-open`, default on) +
|
||||
`Data/FeatureFlagStore.cs` (`FeatureFlagEntity` in SQLite + migration; `All()` merges catalog
|
||||
defaults with overrides, `IsEnabled`, `Set` rejects unknown keys). `GET /flags` (readable, drives
|
||||
FE gating) + `PUT /admin/flags/{key}` (gated by new `flags:manage` cap + `FlagsAdmin`). Enforced
|
||||
end-to-end: `POST /applications` for a `registratie` returns 403 when `inschrijving-open` is off.
|
||||
- FE: `shared/domain/feature-flag.ts` + `feature-flags.adapter.ts` (parse boundary) +
|
||||
`shared/application/feature-flags.store.ts` (root singleton, `enabled(key)` deny-by-default,
|
||||
`set`). Capability `flags:manage` (union + me.adapter + role.interceptor `/api/v1/admin/flags`).
|
||||
The "Inschrijven" nav item + dashboard action hide when the flag is off. Admin toggle page
|
||||
`beheer/ui/feature-flags.page.ts` at `/beheer/functies`, in `ADMIN_LINKS`.
|
||||
- Tests: catalog-unique + endpoint (admin-only toggle, 404 unknown key, close→403 / reopen→201).
|
||||
`/me` cap-list test updated. Backend 136; typed client regenerated.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Admin toggles a flag at runtime; state persists (SQLite) and the whole app reads it.
|
||||
- [x] FE hides the flagged feature AND the backend enforces it (registration close → 403).
|
||||
- [x] `npm run ci` green (dep:check, localized build, backend `dotnet test`, drift clean after commit).
|
||||
@@ -0,0 +1,40 @@
|
||||
# WP-48 — Stamdata deletion protection (referential integrity)
|
||||
|
||||
Status: done
|
||||
Phase: 8 — platform/DX/showcase
|
||||
|
||||
## Why
|
||||
|
||||
Deleting a stamdata row that something relies on (e.g. a `professions.program` a diploma maps
|
||||
through) would silently break behaviour. Stamdata is config-as-code (PR-applied, CI-gated), so the
|
||||
authoritative guard belongs at the build gate; the editor gets a fast-feedback nudge.
|
||||
|
||||
## Decisions (locked with the user)
|
||||
|
||||
- **CI gate (authoritative) + editor warning (fast feedback).**
|
||||
- **Steer temporal rows toward expiring** (set `geldigTot`) over hard delete.
|
||||
|
||||
## Outcome
|
||||
|
||||
- **CI gate:** generalized the dangling-reference test in `StamdataValidationTests` into a declared,
|
||||
extensible reference list (`StamdataRef` records) — "every declared reference into a stamdata key
|
||||
resolves against the currently-valid stamdata." Today one entry: `Diploma.Opleiding →
|
||||
professions.program (valid today)`. Resolvers use the "valid today" view (`Professions.ByProgram`),
|
||||
so removing/renaming a referenced program OR expiring it while current data still references it
|
||||
**fails the PR build**; expiring once nothing current relies on it passes. Adding a future FK is
|
||||
one list entry.
|
||||
- **Editor (fast feedback):** `stamdata-table-editor` now confirms before delete (`@@beheer.removeConfirm`
|
||||
— warns that a referenced row fails CI and, for a dated table, to close validity instead) and, for
|
||||
**temporal** tables, adds a **"Sluiten per vandaag"** action that sets `geldigTot` to today
|
||||
(reusing `CellEdited`) — steering to expire over hard delete. CI stays the authority.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A delete/expire that orphans a declared reference fails the build gate (existing seed passes).
|
||||
- [x] Editor confirms deletes and offers expire (close validity) for temporal tables.
|
||||
- [x] `npm run ci` green (backend `dotnet test`, localized build).
|
||||
|
||||
## Deferred (noted)
|
||||
|
||||
A per-row "referenced" hint in the editor DTO (server-computed usage) — would let the editor warn on
|
||||
the _specific_ referenced rows rather than a generic confirm. Not needed for the authoritative gate.
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
"gen": "plop",
|
||||
"gen:value-object": "plop value-object",
|
||||
"gen:form-machine": "plop form-machine",
|
||||
"serve:i18n": "ng build --localize && node scripts/serve-i18n.mjs",
|
||||
"serve:i18n": "ng build --configuration development --localize && node scripts/serve-i18n.mjs",
|
||||
"ci": "bash scripts/ci-local.sh",
|
||||
"e2e": "playwright test",
|
||||
"extract-i18n": "ng extract-i18n --output-path src/locale"
|
||||
|
||||
@@ -52,25 +52,23 @@ createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
const url = decodeURIComponent(rawUrl.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';
|
||||
// nl (source) is served at the ROOT (subPath ''); en lives under /en/.
|
||||
const isEn = url === '/en' || url.startsWith('/en/');
|
||||
const indexPath = isEn ? join(ROOT, 'en', 'index.html') : join(ROOT, 'index.html');
|
||||
try {
|
||||
// A real asset (nl at root, en under /en/) — otherwise fall through to the SPA index.
|
||||
if (url === '/' || url === '/en' || url === '/en/') throw new Error('serve index');
|
||||
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');
|
||||
send(res, 200, await readFile(indexPath), 'text/html');
|
||||
} catch {
|
||||
send(res, 404, 'Not found', 'text/plain');
|
||||
}
|
||||
}
|
||||
}).listen(PORT, () => {
|
||||
console.log(`Serving ${ROOT} at http://localhost:${PORT}/ (→ /nl/, /en/)`);
|
||||
console.log(`Serving ${ROOT} at http://localhost:${PORT}/ (nl at /, en at /en/)`);
|
||||
});
|
||||
|
||||
@@ -91,6 +91,13 @@ export const routes: Routes = [
|
||||
canActivate: [capabilityGuard('cases:manage')],
|
||||
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
|
||||
},
|
||||
{
|
||||
path: 'beheer/functies',
|
||||
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`.
|
||||
canActivate: [capabilityGuard('flags:manage')],
|
||||
loadComponent: () =>
|
||||
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
|
||||
},
|
||||
{
|
||||
path: 'concepts',
|
||||
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
|
||||
|
||||
@@ -11,7 +11,7 @@ const STORAGE_KEY = 'session-v1';
|
||||
unused after login; only `naam` is shown in the chrome. */
|
||||
function restore(): Session | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
||||
@@ -24,10 +24,12 @@ function restore(): Session | null {
|
||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
||||
* there is exactly one instance — every component that injects it sees the same
|
||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
||||
* header, etc.). The session is mirrored to sessionStorage so a refresh or a
|
||||
* deep-link to a protected route keeps you logged in; it clears when the tab
|
||||
* closes. ponytail: sessionStorage, not localStorage — no cross-tab sync, which
|
||||
* matches a single-session portal.
|
||||
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
|
||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
||||
* in an httpOnly cookie/token, not web storage.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStore {
|
||||
@@ -41,8 +43,8 @@ export class SessionStore {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||
if (s) sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
else sessionStorage.removeItem(STORAGE_KEY);
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
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 { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||
|
||||
/**
|
||||
* Admin page: toggle runtime feature flags (WP-47). Deny-by-default capability gate
|
||||
* (`flags:manage`). The catalog is server-owned (code); this only flips the on/off state, which
|
||||
* the whole app reads via the same `FeatureFlagStore`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-feature-flags-page',
|
||||
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC],
|
||||
styles: [
|
||||
`
|
||||
.flag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--rhc-space-max-lg);
|
||||
padding: var(--rhc-space-max-md) 0;
|
||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200);
|
||||
}
|
||||
.flag .meta {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.flag .key {
|
||||
font-family: monospace;
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
color: var(--rhc-color-grijs-700);
|
||||
}
|
||||
.state {
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
margin-inline-end: var(--rhc-space-max-md);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
|
||||
@if (!access.ready()) {
|
||||
<!-- wait for /me before deciding -->
|
||||
} @else if (!canManage()) {
|
||||
<app-alert type="error">{{ deniedText }}</app-alert>
|
||||
} @else {
|
||||
<app-async [data]="store.flags()">
|
||||
<ng-template appAsyncError>
|
||||
<app-alert type="error">{{ failedText }}</app-alert>
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoaded>
|
||||
@for (f of store.all(); track f.key) {
|
||||
<div class="flag">
|
||||
<div class="meta">
|
||||
<div>{{ f.description }}</div>
|
||||
<div class="key">{{ f.key }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="state">{{ f.enabled ? onText : offText }}</span>
|
||||
<app-button
|
||||
[variant]="f.enabled ? 'secondary' : 'primary'"
|
||||
(click)="toggle(f.key, !f.enabled)"
|
||||
>{{ f.enabled ? disableText : enableText }}</app-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-async>
|
||||
}
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class FeatureFlagsPage {
|
||||
protected store = inject(FeatureFlagStore);
|
||||
protected access = inject(AccessStore);
|
||||
|
||||
protected canManage = computed(() => this.access.can('flags:manage'));
|
||||
|
||||
protected heading = $localize`:@@flags.heading:Functievlaggen`;
|
||||
protected intro = $localize`:@@flags.intro:Zet functionaliteit aan of uit tijdens runtime. De catalogus staat vast in code; hier beheert u de status.`;
|
||||
protected deniedText = $localize`:@@flags.denied:U hebt geen rechten om functievlaggen te beheren.`;
|
||||
protected failedText = $localize`:@@flags.failed:De functievlaggen konden niet worden geladen.`;
|
||||
protected retryText = $localize`:@@flags.retry:Opnieuw proberen`;
|
||||
protected onText = $localize`:@@flags.on:Aan`;
|
||||
protected offText = $localize`:@@flags.off:Uit`;
|
||||
protected enableText = $localize`:@@flags.enable:Aanzetten`;
|
||||
protected disableText = $localize`:@@flags.disable:Uitzetten`;
|
||||
|
||||
protected toggle(key: string, enabled: boolean) {
|
||||
void this.store.set(key, enabled);
|
||||
}
|
||||
protected reload() {
|
||||
void this.store.load();
|
||||
}
|
||||
}
|
||||
@@ -145,10 +145,15 @@ interface DisplayRow {
|
||||
}
|
||||
<td>
|
||||
@if (!previewing()) {
|
||||
@if (table().temporal) {
|
||||
<app-button variant="subtle" (click)="onExpire(item.index)">{{
|
||||
expireLabel
|
||||
}}</app-button>
|
||||
}
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[attr.aria-label]="removeLabel"
|
||||
(click)="rowRemoved.emit(item.index)"
|
||||
(click)="onRemove(item.index)"
|
||||
>{{ removeLabel }}</app-button
|
||||
>
|
||||
}
|
||||
@@ -234,6 +239,21 @@ export class StamdataTableEditorComponent {
|
||||
protected previewNote = $localize`:@@beheer.previewNote:Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.`;
|
||||
protected actionsLabel = $localize`:@@beheer.actions:Acties`;
|
||||
protected removeLabel = $localize`:@@beheer.remove:Verwijderen`;
|
||||
protected expireLabel = $localize`:@@beheer.expire:Sluiten per vandaag`;
|
||||
private removeConfirm = $localize`:@@beheer.removeConfirm:Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.`;
|
||||
|
||||
/** Deletions can orphan a reference (the CI gate catches it); confirm first (WP-48). */
|
||||
protected onRemove(index: number) {
|
||||
if (confirm(this.removeConfirm)) this.rowRemoved.emit(index);
|
||||
}
|
||||
|
||||
/** Steer temporal tables toward expiring (close the validity per today) over hard delete —
|
||||
preserves history and can't orphan a reference that was valid earlier (WP-48). */
|
||||
protected onExpire(index: number) {
|
||||
const col = this.table().columns.find((c) => /geldigtot/i.test(c.name));
|
||||
if (col) this.cellEdited.emit({ row: index, column: col.name, value: this.today });
|
||||
}
|
||||
private today = new Date().toISOString().slice(0, 10);
|
||||
protected undoLabel = $localize`:@@beheer.undo:Ongedaan maken`;
|
||||
protected redoLabel = $localize`:@@beheer.redo:Opnieuw uitvoeren`;
|
||||
protected addRowLabel = $localize`:@@beheer.addRow:Rij toevoegen`;
|
||||
|
||||
@@ -11,6 +11,8 @@ import { ApplicationListComponent } from '@shared/ui/application-list/applicatio
|
||||
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||
import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag';
|
||||
import { ADMIN_LINKS } from '@shared/layout/admin-links';
|
||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
|
||||
@@ -176,7 +178,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
|
||||
<section>
|
||||
<app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading>
|
||||
<app-application-list class="app-section">
|
||||
@for (a of acties; track a.to) {
|
||||
@for (a of acties(); track a.to) {
|
||||
<li
|
||||
app-application-link
|
||||
[heading]="a.titel"
|
||||
@@ -211,6 +213,7 @@ export class DashboardPage {
|
||||
protected store = inject(BigProfileStore);
|
||||
private apps = inject(ApplicationsStore);
|
||||
private access = inject(AccessStore);
|
||||
private flags = inject(FeatureFlagStore);
|
||||
private router = inject(Router);
|
||||
|
||||
/** Admin pages the current principal may reach — capability-gated (never role-derived),
|
||||
@@ -282,7 +285,7 @@ export class DashboardPage {
|
||||
/** Primary transactional actions, as an "aanvragen" list (see CIBG's
|
||||
componenten/aanvragen). The core portal sections live in the header nav now;
|
||||
the teaching pages (concepts/brief) are only reachable from here. */
|
||||
protected readonly acties = [
|
||||
private readonly allActies = [
|
||||
{
|
||||
to: '/registreren',
|
||||
titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`,
|
||||
@@ -320,4 +323,11 @@ export class DashboardPage {
|
||||
actie: $localize`:@@dashboard.actie.brief.actie:Start brief`,
|
||||
},
|
||||
];
|
||||
|
||||
/** Hide the "Inschrijven" action when self-service registration is flagged off (WP-47). */
|
||||
protected readonly acties = computed(() =>
|
||||
this.allActies.filter(
|
||||
(a) => a.to !== '/registreren' || this.flags.enabled(FLAG_INSCHRIJVING_OPEN),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
import { FeatureFlag } from '@shared/domain/feature-flag';
|
||||
import { FeatureFlagsAdapter, parseFlags } from '@shared/infrastructure/feature-flags.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/**
|
||||
* Runtime feature-flag state (WP-47) — one root singleton, mirroring `AccessStore`. Loads the
|
||||
* resolved flag set once from `GET /flags`; `enabled(key)` gates a feature (deny-by-default:
|
||||
* false until loaded / unknown key). `set()` is the admin toggle (PUT + reload). The catalog is
|
||||
* server-owned; the FE only mirrors + renders it.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FeatureFlagStore {
|
||||
private adapter = inject(FeatureFlagsAdapter);
|
||||
private state = signal<RemoteData<Err, FeatureFlag[]>>({ tag: 'Loading' });
|
||||
|
||||
readonly flags = this.state.asReadonly();
|
||||
/** The resolved list (empty until loaded) — for the admin toggle UI. */
|
||||
readonly all = computed(() => {
|
||||
const rd = this.state();
|
||||
return rd.tag === 'Success' ? rd.value : [];
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
|
||||
try {
|
||||
const parsed = parseFlags(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 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Deny-by-default: false while loading/failed or for an unknown key. Reactive (reads the signal). */
|
||||
enabled(key: string): boolean {
|
||||
const rd = this.state();
|
||||
return rd.tag === 'Success' && (rd.value.find((f) => f.key === key)?.enabled ?? false);
|
||||
}
|
||||
|
||||
/** Admin toggle: persist then reload so the state reflects the server. */
|
||||
async set(key: string, enabled: boolean) {
|
||||
try {
|
||||
await this.adapter.set(key, enabled);
|
||||
} finally {
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,5 @@ export type Capability =
|
||||
| 'brief:send'
|
||||
| 'orgtemplate:edit'
|
||||
| 'stamdata:edit'
|
||||
| 'cases:manage';
|
||||
| 'cases:manage'
|
||||
| 'flags:manage';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** A runtime feature flag as the FE sees it (resolved: catalog default + admin override). */
|
||||
export interface FeatureFlag {
|
||||
key: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Known flag keys the FE gates on — must match the backend `FeatureFlags` catalog. */
|
||||
export const FLAG_INSCHRIJVING_OPEN = 'inschrijving-open';
|
||||
@@ -1198,6 +1198,92 @@ export class ApiClient {
|
||||
return Promise.resolve<MeDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
flagsAll(): Promise<FeatureFlagDto[]> {
|
||||
let url_ = this.baseUrl + "/api/v1/flags";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processFlagsAll(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processFlagsAll(response: Response): Promise<FeatureFlagDto[]> {
|
||||
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 FeatureFlagDto[];
|
||||
return result200;
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<FeatureFlagDto[]>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return No Content
|
||||
*/
|
||||
flags(key: string, body: SetFeatureFlagRequest): Promise<void> {
|
||||
let url_ = this.baseUrl + "/api/v1/admin/flags/{key}";
|
||||
if (key === undefined || key === null)
|
||||
throw new globalThis.Error("The parameter 'key' must be defined.");
|
||||
url_ = url_.replace("{key}", encodeURIComponent("" + key));
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
const content_ = JSON.stringify(body);
|
||||
|
||||
let options_: RequestInit = {
|
||||
body: content_,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processFlags(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processFlags(response: Response): Promise<void> {
|
||||
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 === 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return;
|
||||
});
|
||||
} 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 === 404) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("Not Found", status, _responseText, _headers);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<void>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
@@ -1918,6 +2004,12 @@ export interface DuoLookupDto {
|
||||
handmatig?: ManualDiplomaPolicyDto;
|
||||
}
|
||||
|
||||
export interface FeatureFlagDto {
|
||||
key?: string | undefined;
|
||||
description?: string | undefined;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface HerregistratieDecisionsDto {
|
||||
eligibleForHerregistratie?: boolean;
|
||||
herregistratieReason?: string | undefined;
|
||||
@@ -2098,6 +2190,10 @@ export interface SaveOrgTemplateRequest {
|
||||
draft?: OrgTemplateDto;
|
||||
}
|
||||
|
||||
export interface SetFeatureFlagRequest {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface StamdataColumnDto {
|
||||
name?: string | undefined;
|
||||
type?: string | undefined;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { FeatureFlag } from '@shared/domain/feature-flag';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for feature flags (WP-47): `GET /flags` (resolved set, drives FE gating)
|
||||
* and the admin `PUT /admin/flags/{key}`. The single place the ApiClient lives for flags; the
|
||||
* store parses at the boundary.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FeatureFlagsAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
list() {
|
||||
return this.client.flagsAll();
|
||||
}
|
||||
set(key: string, enabled: boolean) {
|
||||
return this.client.flags(key, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust-boundary parse of the flag set. */
|
||||
export function parseFlags(json: unknown): Result<string, FeatureFlag[]> {
|
||||
if (!Array.isArray(json)) return err('flags: not an array');
|
||||
const out: FeatureFlag[] = [];
|
||||
for (const f of json) {
|
||||
if (typeof f !== 'object' || f === null) return err('flags: row not an object');
|
||||
const d = f as Partial<FeatureFlag>;
|
||||
if (typeof d.key !== 'string' || typeof d.enabled !== 'boolean') return err('flags: bad shape');
|
||||
out.push({
|
||||
key: d.key,
|
||||
description: typeof d.description === 'string' ? d.description : '',
|
||||
enabled: d.enabled,
|
||||
});
|
||||
}
|
||||
return ok(out);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const KNOWN: readonly Capability[] = [
|
||||
'orgtemplate:edit',
|
||||
'stamdata:edit',
|
||||
'cases:manage',
|
||||
'flags:manage',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ const ROLE_AWARE = [
|
||||
'/api/v1/admin/org-template',
|
||||
'/api/v1/admin/cases',
|
||||
'/api/v1/admin/audit',
|
||||
'/api/v1/admin/flags',
|
||||
'/api/v1/stamdata',
|
||||
'/api/v1/me',
|
||||
];
|
||||
|
||||
@@ -37,4 +37,10 @@ export const ADMIN_LINKS: readonly AdminLink[] = [
|
||||
to: '/beheer/audit',
|
||||
cap: 'cases:manage',
|
||||
},
|
||||
{
|
||||
label: $localize`:@@header.nav.functies:Functievlaggen`,
|
||||
description: $localize`:@@admin.link.functies.desc:Functionaliteit aan- of uitzetten`,
|
||||
to: '/beheer/functies',
|
||||
cap: 'flags:manage',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
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');
|
||||
describe('localeLinks (nl at root, en under /en/)', () => {
|
||||
it('an nl route (no prefix) links nl to the bare path, en under /en, marks active', () => {
|
||||
const links = localeLinks('/dashboard', 'nl');
|
||||
expect(links.map((l) => [l.locale, l.href, l.active])).toEqual([
|
||||
['nl', '/nl/dashboard', true],
|
||||
['nl', '/dashboard', true],
|
||||
['en', '/en/dashboard', false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('swaps the prefix and keeps a deep path (en active)', () => {
|
||||
it('an en route strips the /en prefix for the nl target (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 === 'nl')!.href).toBe('/beheer/audit');
|
||||
expect(links.find((l) => l.locale === 'en')!.active).toBe(true);
|
||||
});
|
||||
|
||||
it('handles an unprefixed dev path (served at /), keeps query + hash', () => {
|
||||
it('keeps query + hash on both targets', () => {
|
||||
const links = localeLinks('/registreren', 'nl', '?scenario=slow', '#top');
|
||||
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/registreren?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/');
|
||||
it('the root maps nl → / and en → /en/', () => {
|
||||
const links = localeLinks('/', 'nl');
|
||||
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/');
|
||||
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,10 +16,11 @@ const LOCALES: readonly { locale: Locale; label: string }[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Build the two language links for the switcher. Compile-time i18n serves the source locale
|
||||
* (nl) at the ROOT (`subPath: ''`) and en under `/en/`, so switching is a full navigation to the
|
||||
* sibling bundle at the same route. Strips a leading `/en` from the current path, then targets nl
|
||||
* at the bare path and en under `/en`. Keeps query + hash. Pure — no DOM (the component passes
|
||||
* `location.*` in).
|
||||
*/
|
||||
export function localeLinks(
|
||||
pathname: string,
|
||||
@@ -27,11 +28,12 @@ export function localeLinks(
|
||||
search = '',
|
||||
hash = '',
|
||||
): LocaleLink[] {
|
||||
const rest = pathname.replace(/^\/(nl|en)(?=\/|$)/, '') || '/';
|
||||
const rest = pathname.replace(/^\/en(?=\/|$)/, '') || '/';
|
||||
const href = (locale: Locale) => `${locale === 'en' ? `/en${rest}` : rest}${search}${hash}`;
|
||||
return LOCALES.map(({ locale, label }) => ({
|
||||
locale,
|
||||
label,
|
||||
href: `/${locale}${rest}${search}${hash}`,
|
||||
href: href(locale),
|
||||
active: locale === active,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/ro
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import { SESSION_PORT } from '@shared/application/session.port';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||
import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag';
|
||||
import { ADMIN_LINKS } from '@shared/layout/admin-links';
|
||||
import { BreadcrumbComponent } from '@shared/layout/breadcrumb/breadcrumb.component';
|
||||
import { trailFor } from '@shared/layout/breadcrumb/breadcrumb-trail';
|
||||
@@ -87,7 +89,7 @@ const NAV_ITEMS: readonly HeaderNavItem[] = [
|
||||
<nav i18n-aria-label="@@header.navAria" aria-label="Hoofdnavigatie">
|
||||
<div class="container">
|
||||
<ul>
|
||||
@for (item of navItems; track item.to) {
|
||||
@for (item of navItems(); track item.to) {
|
||||
<li routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
|
||||
<a [routerLink]="item.to">{{ item.label }}</a>
|
||||
</li>
|
||||
@@ -104,11 +106,15 @@ const NAV_ITEMS: readonly HeaderNavItem[] = [
|
||||
`,
|
||||
})
|
||||
export class SiteHeaderComponent {
|
||||
protected readonly navItems = NAV_ITEMS;
|
||||
private access = inject(AccessStore);
|
||||
private flags = inject(FeatureFlagStore);
|
||||
/** Hide "Inschrijven" when self-service registration is flagged off (WP-47). */
|
||||
protected readonly navItems = computed(() =>
|
||||
NAV_ITEMS.filter((i) => i.to !== '/registreren' || this.flags.enabled(FLAG_INSCHRIJVING_OPEN)),
|
||||
);
|
||||
|
||||
private router = inject(Router);
|
||||
private sessionPort = inject(SESSION_PORT, { optional: true });
|
||||
private access = inject(AccessStore);
|
||||
/** Injecting AccessStore here also warms `/me` at app start (the header renders on
|
||||
every page), so the admin routes' guard usually finds caps already resolved. */
|
||||
protected adminItems = computed(() => ADMIN_LINKS.filter((i) => this.access.can(i.cap)));
|
||||
|
||||
@@ -3016,6 +3016,14 @@
|
||||
<context context-type="linenumber">226</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.expire" datatype="html">
|
||||
<source>Sluiten per vandaag</source>
|
||||
<target datatype="html">Close as of today</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.removeConfirm" datatype="html">
|
||||
<source>Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.</source>
|
||||
<target datatype="html">Delete this row? If other data references it, the build check (CI) will fail. For a table with a validity period, prefer closing the row (valid until) over deleting it.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.addRow" datatype="html">
|
||||
<source>Rij toevoegen</source>
|
||||
<target datatype="html">Add row</target>
|
||||
@@ -3682,6 +3690,50 @@
|
||||
<source>Auditlog</source>
|
||||
<target datatype="html">Audit log</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="header.nav.functies" datatype="html">
|
||||
<source>Functievlaggen</source>
|
||||
<target datatype="html">Feature flags</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="admin.link.functies.desc" datatype="html">
|
||||
<source>Functionaliteit aan- of uitzetten</source>
|
||||
<target datatype="html">Turn functionality on or off</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.heading" datatype="html">
|
||||
<source>Functievlaggen</source>
|
||||
<target datatype="html">Feature flags</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.intro" datatype="html">
|
||||
<source>Zet functionaliteit aan of uit tijdens runtime. De catalogus staat vast in code; hier beheert u de status.</source>
|
||||
<target datatype="html">Turn functionality on or off at runtime. The catalog is fixed in code; here you manage the state.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.denied" datatype="html">
|
||||
<source>U hebt geen rechten om functievlaggen te beheren.</source>
|
||||
<target datatype="html">You do not have permission to manage feature flags.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.failed" datatype="html">
|
||||
<source>De functievlaggen konden niet worden geladen.</source>
|
||||
<target datatype="html">The feature flags could not be loaded.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.retry" datatype="html">
|
||||
<source>Opnieuw proberen</source>
|
||||
<target datatype="html">Try again</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.on" datatype="html">
|
||||
<source>Aan</source>
|
||||
<target datatype="html">On</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.off" datatype="html">
|
||||
<source>Uit</source>
|
||||
<target datatype="html">Off</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.enable" datatype="html">
|
||||
<source>Aanzetten</source>
|
||||
<target datatype="html">Turn on</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.disable" datatype="html">
|
||||
<source>Uitzetten</source>
|
||||
<target datatype="html">Turn off</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>
|
||||
|
||||
+149
-58
@@ -178,102 +178,179 @@
|
||||
<context context-type="linenumber">113</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.heading" datatype="html">
|
||||
<source>Functievlaggen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/feature-flags.page.ts</context>
|
||||
<context context-type="linenumber">82</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.intro" datatype="html">
|
||||
<source>Zet functionaliteit aan of uit tijdens runtime. De catalogus staat vast in code; hier beheert u de status.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/feature-flags.page.ts</context>
|
||||
<context context-type="linenumber">83</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.denied" datatype="html">
|
||||
<source>U hebt geen rechten om functievlaggen te beheren.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/feature-flags.page.ts</context>
|
||||
<context context-type="linenumber">84</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.failed" datatype="html">
|
||||
<source>De functievlaggen konden niet worden geladen.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/feature-flags.page.ts</context>
|
||||
<context context-type="linenumber">85</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.retry" datatype="html">
|
||||
<source>Opnieuw proberen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/feature-flags.page.ts</context>
|
||||
<context context-type="linenumber">86</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.on" datatype="html">
|
||||
<source>Aan</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/feature-flags.page.ts</context>
|
||||
<context context-type="linenumber">87</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.off" datatype="html">
|
||||
<source>Uit</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/feature-flags.page.ts</context>
|
||||
<context context-type="linenumber">88</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.enable" datatype="html">
|
||||
<source>Aanzetten</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/feature-flags.page.ts</context>
|
||||
<context context-type="linenumber">89</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.disable" datatype="html">
|
||||
<source>Uitzetten</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/feature-flags.page.ts</context>
|
||||
<context context-type="linenumber">90</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.added" datatype="html">
|
||||
<source>toegevoegd</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">223</context>
|
||||
<context context-type="linenumber">228</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.edited" datatype="html">
|
||||
<source>gewijzigd</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">224</context>
|
||||
<context context-type="linenumber">229</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.removed" datatype="html">
|
||||
<source>verwijderd</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">225</context>
|
||||
<context context-type="linenumber">230</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.table" datatype="html">
|
||||
<source>Tabel</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">231</context>
|
||||
<context context-type="linenumber">236</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.peildatum" datatype="html">
|
||||
<source>Toon geldig op</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">232</context>
|
||||
<context context-type="linenumber">237</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.showAll" datatype="html">
|
||||
<source>Toon alles</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">233</context>
|
||||
<context context-type="linenumber">238</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.previewNote" datatype="html">
|
||||
<source>Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">234</context>
|
||||
<context context-type="linenumber">239</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.actions" datatype="html">
|
||||
<source>Acties</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">235</context>
|
||||
<context context-type="linenumber">240</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.remove" datatype="html">
|
||||
<source>Verwijderen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">236</context>
|
||||
<context context-type="linenumber">241</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.expire" datatype="html">
|
||||
<source>Sluiten per vandaag</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">242</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.removeConfirm" datatype="html">
|
||||
<source>Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">243</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.undo" datatype="html">
|
||||
<source>Ongedaan maken</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">237</context>
|
||||
<context context-type="linenumber">257</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.redo" datatype="html">
|
||||
<source>Opnieuw uitvoeren</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">238</context>
|
||||
<context context-type="linenumber">258</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.addRow" datatype="html">
|
||||
<source>Rij toevoegen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">239</context>
|
||||
<context context-type="linenumber">259</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.download" datatype="html">
|
||||
<source>Download JSON</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">240</context>
|
||||
<context context-type="linenumber">260</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.applyHint" datatype="html">
|
||||
<source>Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||
<context context-type="linenumber">241</context>
|
||||
<context context-type="linenumber">261</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="beheer.page.heading" datatype="html">
|
||||
@@ -2057,235 +2134,235 @@
|
||||
<source>Mijn overzicht</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">47,48</context>
|
||||
<context context-type="linenumber">49,50</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.intro" datatype="html">
|
||||
<source>Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">49,51</context>
|
||||
<context context-type="linenumber">51,53</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.mijnAanvragen" datatype="html">
|
||||
<source>Mijn aanvragen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">65,67</context>
|
||||
<context context-type="linenumber">67,69</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.pendingHerregistratie" datatype="html">
|
||||
<source>Uw herregistratie-aanvraag is in behandeling.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">87,91</context>
|
||||
<context context-type="linenumber">89,93</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.watMoetIkRegelen" datatype="html">
|
||||
<source>Wat moet ik regelen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">101,103</context>
|
||||
<context context-type="linenumber">103,105</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">106,108</context>
|
||||
<context context-type="linenumber">108,110</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.nietsOpenstaan" datatype="html">
|
||||
<source> U heeft op dit moment niets openstaan. </source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">109,110</context>
|
||||
<context context-type="linenumber">111,112</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.mijnRegistratie" datatype="html">
|
||||
<source>Mijn registratie</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">116,118</context>
|
||||
<context context-type="linenumber">118,120</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.persoonsgegevens" datatype="html">
|
||||
<source>Persoonsgegevens (BRP)</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">124,126</context>
|
||||
<context context-type="linenumber">126,128</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.straat" datatype="html">
|
||||
<source>Straat</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">130</context>
|
||||
<context context-type="linenumber">132</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.postcode" datatype="html">
|
||||
<source>Postcode</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">135,136</context>
|
||||
<context context-type="linenumber">137,138</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.woonplaats" datatype="html">
|
||||
<source>Woonplaats</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">141,142</context>
|
||||
<context context-type="linenumber">143,144</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.specialismen" datatype="html">
|
||||
<source>Specialismen en aantekeningen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">155,157</context>
|
||||
<context context-type="linenumber">157,159</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.geenSpecialismen" datatype="html">
|
||||
<source> U heeft nog geen specialismen of aantekeningen. </source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">169,171</context>
|
||||
<context context-type="linenumber">171,173</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.watWiltUDoen" datatype="html">
|
||||
<source>Wat wilt u doen?</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">177,178</context>
|
||||
<context context-type="linenumber">179,180</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.beheer" datatype="html">
|
||||
<source>Beheer</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">193,194</context>
|
||||
<context context-type="linenumber">195,196</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.inschrijven.titel" datatype="html">
|
||||
<source>Inschrijven</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">288</context>
|
||||
<context context-type="linenumber">291</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.inschrijven.tekst" datatype="html">
|
||||
<source>Schrijf u in in het BIG-register via de registratiewizard.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">289</context>
|
||||
<context context-type="linenumber">292</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.inschrijven.actie" datatype="html">
|
||||
<source>Start inschrijving</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">290</context>
|
||||
<context context-type="linenumber">293</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.herregistratie.titel" datatype="html">
|
||||
<source>Herregistratie aanvragen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">294</context>
|
||||
<context context-type="linenumber">297</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.herregistratie.tekst" datatype="html">
|
||||
<source>Verleng uw registratie voor de komende periode.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">295</context>
|
||||
<context context-type="linenumber">298</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.herregistratie.actie" datatype="html">
|
||||
<source>Vraag aan</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">296</context>
|
||||
<context context-type="linenumber">299</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.intake.titel" datatype="html">
|
||||
<source>Herregistratie-intake</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">300</context>
|
||||
<context context-type="linenumber">303</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.intake.tekst" datatype="html">
|
||||
<source>Vragenlijst met vertakkingen.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">301</context>
|
||||
<context context-type="linenumber">304</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.intake.actie" datatype="html">
|
||||
<source>Start intake</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">302</context>
|
||||
<context context-type="linenumber">305</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.wijzigen.titel" datatype="html">
|
||||
<source>Gegevens wijzigen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">306</context>
|
||||
<context context-type="linenumber">309</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.wijzigen.tekst" datatype="html">
|
||||
<source>Bekijk uw gegevens of geef een wijziging door.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">307</context>
|
||||
<context context-type="linenumber">310</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.wijzigen.actie" datatype="html">
|
||||
<source>Bekijk gegevens</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">308</context>
|
||||
<context context-type="linenumber">311</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.concepten.titel" datatype="html">
|
||||
<source>Functionele patronen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">312</context>
|
||||
<context context-type="linenumber">315</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.concepten.tekst" datatype="html">
|
||||
<source>Bekijk de FP/TEA-bouwstenen van deze POC.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">313</context>
|
||||
<context context-type="linenumber">316</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.concepten.actie" datatype="html">
|
||||
<source>Bekijk patronen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">314</context>
|
||||
<context context-type="linenumber">317</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.brief.titel" datatype="html">
|
||||
<source>Brief opstellen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">318</context>
|
||||
<context context-type="linenumber">321</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.brief.tekst" datatype="html">
|
||||
<source>Stel een brief samen uit vaste en vrije onderdelen.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">319</context>
|
||||
<context context-type="linenumber">322</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="dashboard.actie.brief.actie" datatype="html">
|
||||
<source>Start brief</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/dashboard.page.ts</context>
|
||||
<context context-type="linenumber">320</context>
|
||||
<context context-type="linenumber">323</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="registratie.kanaalEmail" datatype="html">
|
||||
@@ -2782,6 +2859,20 @@
|
||||
<context context-type="linenumber">36</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="header.nav.functies" datatype="html">
|
||||
<source>Functievlaggen</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/admin-links.ts</context>
|
||||
<context context-type="linenumber">41</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="admin.link.functies.desc" datatype="html">
|
||||
<source>Functionaliteit aan- of uitzetten</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/admin-links.ts</context>
|
||||
<context context-type="linenumber">42</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="crumb.dashboard" datatype="html">
|
||||
<source>Mijn overzicht</source>
|
||||
<context-group purpose="location">
|
||||
@@ -2842,14 +2933,14 @@
|
||||
<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 context-type="linenumber">80</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 context-type="linenumber">81</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="pageShell.backLabel" datatype="html">
|
||||
@@ -2926,56 +3017,56 @@
|
||||
<source>Overzicht</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||
<context context-type="linenumber">17</context>
|
||||
<context context-type="linenumber">19</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="header.nav.gegevens" datatype="html">
|
||||
<source>Mijn gegevens</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||
<context context-type="linenumber">18</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="header.nav.herregistratie" datatype="html">
|
||||
<source>Herregistratie</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||
<context context-type="linenumber">19</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="header.nav.inschrijven" datatype="html">
|
||||
<source>Inschrijven</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
<context context-type="linenumber">22</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="header.sender" datatype="html">
|
||||
<source>BIG-register</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||
<context context-type="linenumber">55,56</context>
|
||||
<context context-type="linenumber">57,58</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="header.ministry" datatype="html">
|
||||
<source>Ministerie van Volksgezondheid, Welzijn en Sport</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||
<context context-type="linenumber">57,59</context>
|
||||
<context context-type="linenumber">59,61</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="header.uitloggen" datatype="html">
|
||||
<source> Uitloggen </source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||
<context context-type="linenumber">79,80</context>
|
||||
<context context-type="linenumber">81,82</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="header.navAria" datatype="html">
|
||||
<source>Hoofdnavigatie</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||
<context context-type="linenumber">87,88</context>
|
||||
<context context-type="linenumber">89,90</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="wizard.naarStap" datatype="html">
|
||||
|
||||
Reference in New Issue
Block a user