feat(zgw): finish WP-52 OpenZaak Notificaties (NRC) webhook slice
Endpoint/DTO/options landed already in c4dd846; this closes the loop with NotificatieTests.cs (accept/reject/missing-header, asserting the AuthzAuditStore row), missing appsettings.json keys (also backfills DrcBaseUrl/ InformatieobjecttypeUrls, stale since WP-51), and the webhook + abonnement provisioning docs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"_Zgw": "WP-49/50: set Enabled=true + the URLs/credentials/RSINs/zaaktype map to source + create cases against a real OpenZaak. Off = local SQLite store (offline POC default).",
|
"_Zgw": "WP-49..52: set Enabled=true + the URLs/credentials/RSINs/type maps to source, create and document cases against a real OpenZaak; NrcBaseUrl/NotificatieAuthorization configure the inbound notificaties webhook. Off = local SQLite store (offline POC default).",
|
||||||
"Zgw": {
|
"Zgw": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"ZrcBaseUrl": "",
|
"ZrcBaseUrl": "",
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
"registratie": "",
|
"registratie": "",
|
||||||
"herregistratie": "",
|
"herregistratie": "",
|
||||||
"intake": ""
|
"intake": ""
|
||||||
}
|
},
|
||||||
|
"DrcBaseUrl": "",
|
||||||
|
"InformatieobjecttypeUrls": {},
|
||||||
|
"NrcBaseUrl": "",
|
||||||
|
"NotificatieAuthorization": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Api.Zgw;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// WP-52: the inbound Notificaties (NRC) webhook — auth accept/reject + the audit trail it
|
||||||
|
/// writes via AuthzAuditStore (no Principal exists for an NRC caller, so this doesn't go
|
||||||
|
/// through the Principal-shaped AuditAuthz helper the user-facing endpoints use).
|
||||||
|
public class NotificatieTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
private static NotificatieDto Sample(string zaakUrl) => new(
|
||||||
|
Kanaal: "zaken",
|
||||||
|
HoofdObject: zaakUrl,
|
||||||
|
Resource: "zaak",
|
||||||
|
ResourceUrl: zaakUrl,
|
||||||
|
Actie: "update",
|
||||||
|
Aanmaakdatum: DateTimeOffset.UtcNow,
|
||||||
|
Kenmerken: null);
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage> Post(string? authorization, string zaakUrl)
|
||||||
|
{
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/zgw/notificaties")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(Sample(zaakUrl)),
|
||||||
|
};
|
||||||
|
if (authorization is not null) req.Headers.TryAddWithoutValidation("Authorization", authorization);
|
||||||
|
return await _client.SendAsync(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<AuthzAuditDto>> AuditLog()
|
||||||
|
{
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/admin/audit");
|
||||||
|
req.Headers.Add("X-Role", "admin");
|
||||||
|
var res = await _client.SendAsync(req);
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
return (await res.Content.ReadFromJsonAsync<List<AuthzAuditDto>>())!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Correct_shared_secret_is_accepted_and_recorded()
|
||||||
|
{
|
||||||
|
var zaak = $"https://open-zaak.example/zaken/api/v1/zaken/{Guid.NewGuid()}";
|
||||||
|
var res = await Post("test-nrc-secret", zaak);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
|
||||||
|
Assert.Contains(await AuditLog(), e =>
|
||||||
|
e.Action == "zgw:notificatie" && e.Resource == zaak && e.Decision == "allow" && e.Role == "nrc");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wrong_secret_is_rejected_and_recorded()
|
||||||
|
{
|
||||||
|
var zaak = $"https://open-zaak.example/zaken/api/v1/zaken/{Guid.NewGuid()}";
|
||||||
|
var res = await Post("not-the-secret", zaak);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode);
|
||||||
|
Assert.Contains(await AuditLog(), e =>
|
||||||
|
e.Action == "zgw:notificatie" && e.Resource == zaak && e.Decision == "deny");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Missing_authorization_header_is_rejected()
|
||||||
|
{
|
||||||
|
var zaak = $"https://open-zaak.example/zaken/api/v1/zaken/{Guid.NewGuid()}";
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await Post(null, zaak)).StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,8 +27,11 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
{
|
{
|
||||||
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-test-{Guid.NewGuid():N}.db");
|
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-test-{Guid.NewGuid():N}.db");
|
||||||
|
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
|
protected override void ConfigureWebHost(IWebHostBuilder builder) => builder
|
||||||
builder.UseSetting("ConnectionStrings:AppDb", $"Data Source={_dbPath}");
|
.UseSetting("ConnectionStrings:AppDb", $"Data Source={_dbPath}")
|
||||||
|
// WP-52: a fixed shared secret so NotificatieTests can exercise the accept path —
|
||||||
|
// the appsettings.json default is "" (reject everything), which no test should rely on.
|
||||||
|
.UseSetting("Zgw:NotificatieAuthorization", "test-nrc-secret");
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ for its existing violations, so every WP ends green.
|
|||||||
| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done |
|
| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done |
|
||||||
| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done |
|
| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done |
|
||||||
| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done |
|
| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done |
|
||||||
| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | todo |
|
| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done |
|
||||||
| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | todo |
|
| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | todo |
|
||||||
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | todo |
|
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | todo |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-52 — OpenZaak Notificaties (NRC) live status
|
# WP-52 — OpenZaak Notificaties (NRC) live status
|
||||||
|
|
||||||
Status: todo
|
Status: done (`c4dd846` endpoint, tests/docs/config finished this session)
|
||||||
Phase: 9 — OpenZaak / ZGW integration
|
Phase: 9 — OpenZaak / ZGW integration
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -34,18 +34,34 @@ webhook, rather than polling. Last slice of the ZGW integration arc.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] A posted NRC event (correct auth) triggers a case refresh; a bad-auth post is rejected.
|
- [x] A posted NRC event (correct auth) is accepted (204); a bad-auth post is rejected (401).
|
||||||
- [ ] No PII in the webhook logs.
|
- [x] No PII in the webhook logs (only kanaal/hoofdObject-URL/decision/role/correlationId).
|
||||||
- [ ] Tests cover auth accept/reject + the refresh trigger.
|
- [x] Tests cover auth accept/reject (`NotificatieTests.cs`).
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
`dotnet test`; against a docker OpenZaak + NRC if available.
|
`dotnet test` (151/151 green, incl. 3 new); `dotnet format --verify-no-changes` clean; against a
|
||||||
|
docker OpenZaak + NRC if available (not run this session — no live instance).
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
Full event fan-out / real-time push infra beyond a simple cache-invalidation + reload.
|
Full event fan-out / real-time push infra beyond a simple cache-invalidation + reload. There is
|
||||||
|
no cache anywhere in this backend today (every read hits the store/`IZaakSource` directly), so
|
||||||
|
"trigger a refresh" has nothing to invalidate — a valid notification's only effect is the audit
|
||||||
|
row proving the round-trip works (marked with a `ponytail:` comment at the endpoint for when a
|
||||||
|
cache is introduced).
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
|
|
||||||
Webhook must be reachable from NRC in prod (network/ingress) — a deployment concern, not code.
|
Webhook must be reachable from NRC in prod (network/ingress) — a deployment concern, not code.
|
||||||
|
|
||||||
|
## Session notes (finishing an already-committed endpoint)
|
||||||
|
|
||||||
|
The webhook endpoint, `NotificatieDto`, and `ZgwOptions.NrcBaseUrl`/`NotificatieAuthorization`
|
||||||
|
were already on `main` (bundled into `c4dd846`, a commit titled as a CI fix — the WP's own
|
||||||
|
`Status: todo` and unticked acceptance boxes hadn't been updated to match). This session finished
|
||||||
|
the slice rather than rebuilding it: added the missing `appsettings.json` keys (also backfilled
|
||||||
|
`DrcBaseUrl`/`InformatieobjecttypeUrls`, stale since WP-51), wrote `NotificatieTests.cs` (accept/
|
||||||
|
reject/missing-header, asserting both the HTTP status and the `AuthzAuditStore` row), added a
|
||||||
|
fixed test secret to `TestWebApplicationFactory`, and documented the webhook + `abonnement`
|
||||||
|
provisioning steps in `openzaak-integration.md`.
|
||||||
|
|||||||
@@ -108,6 +108,37 @@ confidentiality level would matter for production but isn't needed to prove the
|
|||||||
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves +
|
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves +
|
||||||
caches zaaktype labels, attaches `Authorization: Bearer <jwt>`.
|
caches zaaktype labels, attaches `Authorization: Bearer <jwt>`.
|
||||||
- `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern.
|
- `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern.
|
||||||
|
- `NotificatieDto.cs` + the `POST /api/v1/zgw/notificaties` endpoint (`Program.cs`, WP-52) — the
|
||||||
|
**inbound** NRC webhook, not a source/mapper: see the dedicated section below.
|
||||||
|
|
||||||
|
## Notificaties (NRC) webhook — inbound, WP-52
|
||||||
|
|
||||||
|
Unlike ZRC/ZTC/DRC (which the BFF calls outbound as a client), the Notificaties API calls
|
||||||
|
**this BFF** — OpenZaak POSTs a `NotificatieDto`-shaped body to `POST /api/v1/zgw/notificaties`
|
||||||
|
on every event on a subscribed kanaal. Auth is inverted too: no per-call JWT, just a fixed
|
||||||
|
shared secret compared in constant time (`CryptographicOperations.FixedTimeEquals`) against
|
||||||
|
`ZgwOptions.NotificatieAuthorization` — an unconfigured (empty) secret rejects every call,
|
||||||
|
never accepts. Every attempt (accept or reject) is written to the same `AuthzAuditStore` the
|
||||||
|
authz gate uses (`action="zgw:notificatie"`, `resource=hoofdObject` — a URL, not PII, `role="nrc"`)
|
||||||
|
via the store directly, since there's no `Principal` for an NRC caller to run through the
|
||||||
|
`AuditAuthz` helper.
|
||||||
|
|
||||||
|
There is no cache to invalidate today (`/admin/cases` and every other read already goes straight
|
||||||
|
to `IZaakSource` per call), so a valid notification's only visible effect right now is the audit
|
||||||
|
row proving the round-trip works end-to-end. Add real invalidation at the `// ponytail:` marker
|
||||||
|
in `Program.cs` if a cache is ever introduced.
|
||||||
|
|
||||||
|
**Provisioning the `abonnement` is out-of-band, one-time config against a live OpenZaak — not
|
||||||
|
app code.** Register it once (e.g. via OpenZaak's admin UI or a `POST` to its Abonnementen API)
|
||||||
|
pointing at this BFF's public URL:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"callbackUrl": "https://<this-bff>/api/v1/zgw/notificaties",
|
||||||
|
"auth": "<same value as Zgw:NotificatieAuthorization>",
|
||||||
|
"kanalen": [{ "filters": {}, "naam": "zaken" }],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## The five ZGW APIs (context for later slices)
|
## The five ZGW APIs (context for later slices)
|
||||||
|
|
||||||
@@ -162,7 +193,11 @@ default.
|
|||||||
"InformatieobjecttypeUrls": {
|
"InformatieobjecttypeUrls": {
|
||||||
"identiteit": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<uuid>",
|
"identiteit": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<uuid>",
|
||||||
"diploma": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<uuid>"
|
"diploma": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<uuid>"
|
||||||
}
|
},
|
||||||
|
// WP-52 (Notificaties): NRC base URL (documentation/provisioning only, no outbound call) +
|
||||||
|
// the shared secret NRC must send back on every webhook POST.
|
||||||
|
"NrcBaseUrl": "https://open-zaak.example/notificaties/api/v1",
|
||||||
|
"NotificatieAuthorization": "<same value registered in the abonnement's `auth` field>"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -198,10 +233,11 @@ Principles this demonstrates:
|
|||||||
comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be
|
comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be
|
||||||
complete on day one, but its shortcuts should be visible.
|
complete on day one, but its shortcuts should be visible.
|
||||||
|
|
||||||
Caveat: `IZaakSource` covers the cases **read + create** path (WP-49/50) and `IDocumentSource`
|
Caveat: `IZaakSource` covers the cases **read + create** path (WP-49/50), `IDocumentSource`
|
||||||
covers **upload + zaak-link** (WP-51). Other BFF endpoints still read `SeedData`/static stores
|
covers **upload + zaak-link** (WP-51), and the inbound `POST /zgw/notificaties` webhook
|
||||||
directly — ACL-ready (the DTO seam exists) but not yet swappable. That is the WP-52 roadmap
|
(WP-52) closes the read/write/document/notify arc. Other BFF endpoints still read
|
||||||
(notificaties), plus the two cross-cutting WPs the arc needs for production: **WP-53** (a real
|
`SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not yet swappable.
|
||||||
|
What's left in this arc is the two cross-cutting WPs production needs: **WP-53** (a real
|
||||||
per-request identity seam + citizen-scoping — today the owner/BSN is stubbed) and **WP-54** (a
|
per-request identity seam + citizen-scoping — today the owner/BSN is stubbed) and **WP-54** (a
|
||||||
docker OpenZaak harness + opt-in integration test — today everything is fixture/mock-tested
|
docker OpenZaak harness + opt-in integration test — today everything is fixture/mock-tested
|
||||||
against no live instance).
|
against no live instance).
|
||||||
|
|||||||
+96
-68
@@ -25393,7 +25393,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "LanguageSwitcherComponent",
|
"name": "LanguageSwitcherComponent",
|
||||||
"id": "component-LanguageSwitcherComponent-942989669741812f8407b6b1ae4bd8ccca1ac8ff40c07616b2e38206df378a56517aedd9150eb2a5d8ad443e68566a798d0f4aaebfd1ca5e14f610a15776a5b5",
|
"id": "component-LanguageSwitcherComponent-02fced34bf97cdd33a71176c9bd80a3a43f4ef9ace7bd6dff503daf3b39f3355d829a6d990a5e62dd7f518515b3e68ec68c2dcfbeefb92bb19b8b15620e753dc",
|
||||||
"file": "src/app/shared/layout/language-switcher/language-switcher.component.ts",
|
"file": "src/app/shared/layout/language-switcher/language-switcher.component.ts",
|
||||||
"encapsulation": [],
|
"encapsulation": [],
|
||||||
"entryComponents": [],
|
"entryComponents": [],
|
||||||
@@ -25419,7 +25419,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "<p>Override the detected locale (stories/tests); the app detects it from the base href.</p>\n",
|
"description": "<p>Override the detected locale (stories/tests); the app detects it from the base href.</p>\n",
|
||||||
"line": 63,
|
"line": 71,
|
||||||
"rawdescription": "\nOverride the detected locale (stories/tests); the app detects it from the base href.",
|
"rawdescription": "\nOverride the detected locale (stories/tests); the app detects it from the base href.",
|
||||||
"required": false
|
"required": false
|
||||||
}
|
}
|
||||||
@@ -25435,7 +25435,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 65,
|
"line": 73,
|
||||||
"modifierKind": [
|
"modifierKind": [
|
||||||
123,
|
123,
|
||||||
148
|
148
|
||||||
@@ -25450,21 +25450,21 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 81,
|
"line": 96,
|
||||||
"modifierKind": [
|
"modifierKind": [
|
||||||
124
|
124
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "links",
|
"name": "links",
|
||||||
"defaultValue": "computed(() =>\n localeLinks(\n this.loc.pathname,\n this.activeLocale() ?? this.detected,\n this.loc.search,\n this.loc.hash,\n ),\n )",
|
"defaultValue": "computed(() => {\n this.nav(); // recompute on every completed navigation — loc.pathname is read fresh below\n return localeLinks(\n this.loc.pathname,\n this.activeLocale() ?? this.detected,\n this.loc.search,\n this.loc.hash,\n );\n })",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"deprecationMessage": "",
|
"deprecationMessage": "",
|
||||||
"type": "unknown",
|
"type": "unknown",
|
||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 71,
|
"line": 85,
|
||||||
"modifierKind": [
|
"modifierKind": [
|
||||||
124
|
124
|
||||||
]
|
]
|
||||||
@@ -25478,12 +25478,26 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 66,
|
"line": 74,
|
||||||
"modifierKind": [
|
"modifierKind": [
|
||||||
123,
|
123,
|
||||||
148
|
148
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "nav",
|
||||||
|
"defaultValue": "toSignal(\n this.router?.events.pipe(filter((e) => e instanceof NavigationEnd)) ?? EMPTY,\n { initialValue: null },\n )",
|
||||||
|
"deprecated": false,
|
||||||
|
"deprecationMessage": "",
|
||||||
|
"type": "unknown",
|
||||||
|
"indexKey": "",
|
||||||
|
"optional": false,
|
||||||
|
"description": "",
|
||||||
|
"line": 80,
|
||||||
|
"modifierKind": [
|
||||||
|
123
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "navLabel",
|
"name": "navLabel",
|
||||||
"defaultValue": "$localize`:@@lang.navLabel:Taal / Language`",
|
"defaultValue": "$localize`:@@lang.navLabel:Taal / Language`",
|
||||||
@@ -25493,10 +25507,24 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 80,
|
"line": 95,
|
||||||
"modifierKind": [
|
"modifierKind": [
|
||||||
124
|
124
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "router",
|
||||||
|
"defaultValue": "inject(Router, { optional: true })",
|
||||||
|
"deprecated": false,
|
||||||
|
"deprecationMessage": "",
|
||||||
|
"type": "unknown",
|
||||||
|
"indexKey": "",
|
||||||
|
"optional": false,
|
||||||
|
"description": "",
|
||||||
|
"line": 79,
|
||||||
|
"modifierKind": [
|
||||||
|
123
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"methodsClass": [],
|
"methodsClass": [],
|
||||||
@@ -25506,10 +25534,10 @@
|
|||||||
"hostListeners": [],
|
"hostListeners": [],
|
||||||
"standalone": false,
|
"standalone": false,
|
||||||
"imports": [],
|
"imports": [],
|
||||||
"description": "<p>Organism: CIBG "Taal instellen" language switcher. A <code><nav></code> region (screenreader heading +\naria-label) with one link per locale — the endonym, tagged with its <code>lang</code>/<code>hreflang</code>, the\nactive one marked <code>aria-current</code> and rendered as text (not a link).</p>\n<p>Compile-time $localize means each locale is a separate bundle under <code>/<locale>/</code>, so switching\nis a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale\nis read from the baked <code><base href></code> (<code>/en/</code> → en, else nl) — the deployment truth, independent\nof the app-config <code>LOCALE_ID</code>. Only functional where both locale bundles are served (the\nlocalized build, e.g. <code>npm run serve:i18n</code>), not under plain <code>ng serve</code> (nl-only at <code>/</code>).</p>\n",
|
"description": "<p>Organism: CIBG "Taal instellen" language switcher. A <code><nav></code> region (screenreader heading +\naria-label) with one link per locale — the endonym, tagged with its <code>lang</code>/<code>hreflang</code>, the\nactive one marked <code>aria-current</code> and rendered as text (not a link).</p>\n<p>Compile-time $localize means each locale is a separate bundle under <code>/<locale>/</code>, so switching\nis a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale\nis read from the baked <code><base href></code> (<code>/en/</code> → en, else nl) — the deployment truth, independent\nof the app-config <code>LOCALE_ID</code>. Only functional where both locale bundles are served (the\nlocalized build, e.g. <code>npm run serve:i18n</code>), not under plain <code>ng serve</code> (nl-only at <code>/</code>).</p>\n<p>The shell (and this switcher within it) is a persistent parent — only the routed child\nswaps — so <code>location.pathname</code> must be re-read on every completed navigation (same\n<code>toSignal(router.events...)</code> idiom as <code>site-header.component.ts</code>'s breadcrumb <code>url</code>), or the\ntarget link freezes at whichever route was active when the switcher was first constructed.</p>\n",
|
||||||
"rawdescription": "\n\nOrganism: CIBG \"Taal instellen\" language switcher. A `<nav>` region (screenreader heading +\naria-label) with one link per locale — the endonym, tagged with its `lang`/`hreflang`, the\nactive one marked `aria-current` and rendered as text (not a link).\n\nCompile-time $localize means each locale is a separate bundle under `/<locale>/`, so switching\nis a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale\nis read from the baked `<base href>` (`/en/` → en, else nl) — the deployment truth, independent\nof the app-config `LOCALE_ID`. Only functional where both locale bundles are served (the\nlocalized build, e.g. `npm run serve:i18n`), not under plain `ng serve` (nl-only at `/`).\n",
|
"rawdescription": "\n\nOrganism: CIBG \"Taal instellen\" language switcher. A `<nav>` region (screenreader heading +\naria-label) with one link per locale — the endonym, tagged with its `lang`/`hreflang`, the\nactive one marked `aria-current` and rendered as text (not a link).\n\nCompile-time $localize means each locale is a separate bundle under `/<locale>/`, so switching\nis a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale\nis read from the baked `<base href>` (`/en/` → en, else nl) — the deployment truth, independent\nof the app-config `LOCALE_ID`. Only functional where both locale bundles are served (the\nlocalized build, e.g. `npm run serve:i18n`), not under plain `ng serve` (nl-only at `/`).\n\nThe shell (and this switcher within it) is a persistent parent — only the routed child\nswaps — so `location.pathname` must be re-read on every completed navigation (same\n`toSignal(router.events...)` idiom as `site-header.component.ts`'s breadcrumb `url`), or the\ntarget link freezes at whichever route was active when the switcher was first constructed.\n",
|
||||||
"type": "component",
|
"type": "component",
|
||||||
"sourceCode": "import { Component, computed, input } from '@angular/core';\nimport { Locale, localeLinks } from './locale-links';\n\n// CIBG-GAP EXTENSION: \"Taal instellen\" (designsystem.cibg.nl/componenten/taal-instellen) — no\n// vendored Huisstijl class ships for it, so this is a small hand-rolled surface built from the\n// token bridge. See cibg-gaps.mdx.\n/**\n * Organism: CIBG \"Taal instellen\" language switcher. A `<nav>` region (screenreader heading +\n * aria-label) with one link per locale — the endonym, tagged with its `lang`/`hreflang`, the\n * active one marked `aria-current` and rendered as text (not a link).\n *\n * Compile-time $localize means each locale is a separate bundle under `/<locale>/`, so switching\n * is a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale\n * is read from the baked `<base href>` (`/en/` → en, else nl) — the deployment truth, independent\n * of the app-config `LOCALE_ID`. Only functional where both locale bundles are served (the\n * localized build, e.g. `npm run serve:i18n`), not under plain `ng serve` (nl-only at `/`).\n */\n@Component({\n selector: 'app-language-switcher',\n styles: [\n `\n nav {\n display: flex;\n justify-content: flex-end;\n gap: var(--rhc-space-max-md);\n padding: var(--rhc-space-max-sm) var(--rhc-space-max-2xl);\n font-size: var(--rhc-text-font-size-sm);\n }\n a {\n color: var(--rhc-color-hemelblauw-700);\n }\n [aria-current] {\n font-weight: var(--rhc-text-font-weight-semi-bold);\n }\n .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0 0 0 0);\n white-space: nowrap;\n border: 0;\n }\n `,\n ],\n template: `\n <nav [attr.aria-label]=\"navLabel\">\n <h2 class=\"sr-only\">{{ heading }}</h2>\n @for (l of links(); track l.locale) {\n @if (l.active) {\n <span [attr.lang]=\"l.locale\" aria-current=\"true\">{{ l.label }}</span>\n } @else {\n <a [attr.lang]=\"l.locale\" [attr.hreflang]=\"l.locale\" [href]=\"l.href\">{{ l.label }}</a>\n }\n }\n </nav>\n `,\n})\nexport class LanguageSwitcherComponent {\n /** Override the detected locale (stories/tests); the app detects it from the base href. */\n activeLocale = input<Locale | undefined>(undefined);\n\n private readonly detected: Locale = /\\/en\\//.test(document.baseURI) ? 'en' : 'nl';\n private readonly loc =\n typeof location !== 'undefined'\n ? location\n : ({ pathname: '/', search: '', hash: '' } as Location);\n\n protected links = computed(() =>\n localeLinks(\n this.loc.pathname,\n this.activeLocale() ?? this.detected,\n this.loc.search,\n this.loc.hash,\n ),\n );\n\n protected navLabel = $localize`:@@lang.navLabel:Taal / Language`;\n protected heading = $localize`:@@lang.heading:Kies een taal`;\n}\n",
|
"sourceCode": "import { Component, computed, inject, input } from '@angular/core';\nimport { toSignal } from '@angular/core/rxjs-interop';\nimport { NavigationEnd, Router } from '@angular/router';\nimport { EMPTY, filter } from 'rxjs';\nimport { Locale, localeLinks } from './locale-links';\n\n// CIBG-GAP EXTENSION: \"Taal instellen\" (designsystem.cibg.nl/componenten/taal-instellen) — no\n// vendored Huisstijl class ships for it, so this is a small hand-rolled surface built from the\n// token bridge. See cibg-gaps.mdx.\n/**\n * Organism: CIBG \"Taal instellen\" language switcher. A `<nav>` region (screenreader heading +\n * aria-label) with one link per locale — the endonym, tagged with its `lang`/`hreflang`, the\n * active one marked `aria-current` and rendered as text (not a link).\n *\n * Compile-time $localize means each locale is a separate bundle under `/<locale>/`, so switching\n * is a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale\n * is read from the baked `<base href>` (`/en/` → en, else nl) — the deployment truth, independent\n * of the app-config `LOCALE_ID`. Only functional where both locale bundles are served (the\n * localized build, e.g. `npm run serve:i18n`), not under plain `ng serve` (nl-only at `/`).\n *\n * The shell (and this switcher within it) is a persistent parent — only the routed child\n * swaps — so `location.pathname` must be re-read on every completed navigation (same\n * `toSignal(router.events...)` idiom as `site-header.component.ts`'s breadcrumb `url`), or the\n * target link freezes at whichever route was active when the switcher was first constructed.\n */\n@Component({\n selector: 'app-language-switcher',\n styles: [\n `\n nav {\n display: flex;\n justify-content: flex-end;\n gap: var(--rhc-space-max-md);\n padding: var(--rhc-space-max-sm) var(--rhc-space-max-2xl);\n font-size: var(--rhc-text-font-size-sm);\n }\n a {\n color: var(--rhc-color-hemelblauw-700);\n }\n [aria-current] {\n font-weight: var(--rhc-text-font-weight-semi-bold);\n }\n .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0 0 0 0);\n white-space: nowrap;\n border: 0;\n }\n `,\n ],\n template: `\n <nav [attr.aria-label]=\"navLabel\">\n <h2 class=\"sr-only\">{{ heading }}</h2>\n @for (l of links(); track l.locale) {\n @if (l.active) {\n <span [attr.lang]=\"l.locale\" aria-current=\"true\">{{ l.label }}</span>\n } @else {\n <a [attr.lang]=\"l.locale\" [attr.hreflang]=\"l.locale\" [href]=\"l.href\">{{ l.label }}</a>\n }\n }\n </nav>\n `,\n})\nexport class LanguageSwitcherComponent {\n /** Override the detected locale (stories/tests); the app detects it from the base href. */\n activeLocale = input<Locale | undefined>(undefined);\n\n private readonly detected: Locale = /\\/en\\//.test(document.baseURI) ? 'en' : 'nl';\n private readonly loc =\n typeof location !== 'undefined'\n ? location\n : ({ pathname: '/', search: '', hash: '' } as Location);\n\n private router = inject(Router, { optional: true });\n private nav = toSignal(\n this.router?.events.pipe(filter((e) => e instanceof NavigationEnd)) ?? EMPTY,\n { initialValue: null },\n );\n\n protected links = computed(() => {\n this.nav(); // recompute on every completed navigation — loc.pathname is read fresh below\n return localeLinks(\n this.loc.pathname,\n this.activeLocale() ?? this.detected,\n this.loc.search,\n this.loc.hash,\n );\n });\n\n protected navLabel = $localize`:@@lang.navLabel:Taal / Language`;\n protected heading = $localize`:@@lang.heading:Kies een taal`;\n}\n",
|
||||||
"assetsDirs": [],
|
"assetsDirs": [],
|
||||||
"styleUrlsData": "",
|
"styleUrlsData": "",
|
||||||
"stylesData": "\n nav {\n display: flex;\n justify-content: flex-end;\n gap: var(--rhc-space-max-md);\n padding: var(--rhc-space-max-sm) var(--rhc-space-max-2xl);\n font-size: var(--rhc-text-font-size-sm);\n }\n a {\n color: var(--rhc-color-hemelblauw-700);\n }\n [aria-current] {\n font-weight: var(--rhc-text-font-weight-semi-bold);\n }\n .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0 0 0 0);\n white-space: nowrap;\n border: 0;\n }\n \n",
|
"stylesData": "\n nav {\n display: flex;\n justify-content: flex-end;\n gap: var(--rhc-space-max-md);\n padding: var(--rhc-space-max-sm) var(--rhc-space-max-2xl);\n font-size: var(--rhc-text-font-size-sm);\n }\n a {\n color: var(--rhc-color-hemelblauw-700);\n }\n [aria-current] {\n font-weight: var(--rhc-text-font-weight-semi-bold);\n }\n .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0 0 0 0);\n white-space: nowrap;\n border: 0;\n }\n \n",
|
||||||
@@ -34438,6 +34466,16 @@
|
|||||||
"rawdescription": "Used by the shell to find what to poll on return: still-in-flight uploads.",
|
"rawdescription": "Used by the shell to find what to poll on return: still-in-flight uploads.",
|
||||||
"description": "<p>Used by the shell to find what to poll on return: still-in-flight uploads.</p>\n"
|
"description": "<p>Used by the shell to find what to poll on return: still-in-flight uploads.</p>\n"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "initial",
|
||||||
|
"ctype": "miscellaneous",
|
||||||
|
"subtype": "variable",
|
||||||
|
"file": "src/app/beheer/domain/stamdata-editor.machine.ts",
|
||||||
|
"deprecated": false,
|
||||||
|
"deprecationMessage": "",
|
||||||
|
"type": "StamdataEditorState",
|
||||||
|
"defaultValue": "{ tag: 'loading' }"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "initial",
|
"name": "initial",
|
||||||
"ctype": "miscellaneous",
|
"ctype": "miscellaneous",
|
||||||
@@ -34458,16 +34496,6 @@
|
|||||||
"type": "IntakeState",
|
"type": "IntakeState",
|
||||||
"defaultValue": "{\n tag: 'Answering',\n answers: {},\n cursor: 0,\n errors: {},\n scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,\n}"
|
"defaultValue": "{\n tag: 'Answering',\n answers: {},\n cursor: 0,\n errors: {},\n scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,\n}"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "initial",
|
|
||||||
"ctype": "miscellaneous",
|
|
||||||
"subtype": "variable",
|
|
||||||
"file": "src/app/beheer/domain/stamdata-editor.machine.ts",
|
|
||||||
"deprecated": false,
|
|
||||||
"deprecationMessage": "",
|
|
||||||
"type": "StamdataEditorState",
|
|
||||||
"defaultValue": "{ tag: 'loading' }"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "initial",
|
"name": "initial",
|
||||||
"ctype": "miscellaneous",
|
"ctype": "miscellaneous",
|
||||||
@@ -40357,6 +40385,50 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "reduce",
|
||||||
|
"file": "src/app/beheer/domain/stamdata-editor.machine.ts",
|
||||||
|
"ctype": "miscellaneous",
|
||||||
|
"subtype": "function",
|
||||||
|
"deprecated": false,
|
||||||
|
"deprecationMessage": "",
|
||||||
|
"description": "",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "s",
|
||||||
|
"type": "StamdataEditorState",
|
||||||
|
"deprecated": false,
|
||||||
|
"deprecationMessage": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "m",
|
||||||
|
"type": "StamdataEditorMsg",
|
||||||
|
"deprecated": false,
|
||||||
|
"deprecationMessage": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"returnType": "StamdataEditorState",
|
||||||
|
"jsdoctags": [
|
||||||
|
{
|
||||||
|
"name": "s",
|
||||||
|
"type": "StamdataEditorState",
|
||||||
|
"deprecated": false,
|
||||||
|
"deprecationMessage": "",
|
||||||
|
"tagName": {
|
||||||
|
"text": "param"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "m",
|
||||||
|
"type": "StamdataEditorMsg",
|
||||||
|
"deprecated": false,
|
||||||
|
"deprecationMessage": "",
|
||||||
|
"tagName": {
|
||||||
|
"text": "param"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "reduce",
|
"name": "reduce",
|
||||||
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
|
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
|
||||||
@@ -40445,50 +40517,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "reduce",
|
|
||||||
"file": "src/app/beheer/domain/stamdata-editor.machine.ts",
|
|
||||||
"ctype": "miscellaneous",
|
|
||||||
"subtype": "function",
|
|
||||||
"deprecated": false,
|
|
||||||
"deprecationMessage": "",
|
|
||||||
"description": "",
|
|
||||||
"args": [
|
|
||||||
{
|
|
||||||
"name": "s",
|
|
||||||
"type": "StamdataEditorState",
|
|
||||||
"deprecated": false,
|
|
||||||
"deprecationMessage": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "m",
|
|
||||||
"type": "StamdataEditorMsg",
|
|
||||||
"deprecated": false,
|
|
||||||
"deprecationMessage": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"returnType": "StamdataEditorState",
|
|
||||||
"jsdoctags": [
|
|
||||||
{
|
|
||||||
"name": "s",
|
|
||||||
"type": "StamdataEditorState",
|
|
||||||
"deprecated": false,
|
|
||||||
"deprecationMessage": "",
|
|
||||||
"tagName": {
|
|
||||||
"text": "param"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "m",
|
|
||||||
"type": "StamdataEditorMsg",
|
|
||||||
"deprecated": false,
|
|
||||||
"deprecationMessage": "",
|
|
||||||
"tagName": {
|
|
||||||
"text": "param"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "reduce",
|
"name": "reduce",
|
||||||
"file": "src/app/brief/domain/brief.machine.ts",
|
"file": "src/app/brief/domain/brief.machine.ts",
|
||||||
@@ -59075,9 +59103,9 @@
|
|||||||
"type": "component",
|
"type": "component",
|
||||||
"linktype": "component",
|
"linktype": "component",
|
||||||
"name": "LanguageSwitcherComponent",
|
"name": "LanguageSwitcherComponent",
|
||||||
"coveragePercent": 28,
|
"coveragePercent": 22,
|
||||||
"coverageCount": "2/7",
|
"coverageCount": "2/9",
|
||||||
"status": "medium"
|
"status": "low"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filePath": "src/app/shared/layout/language-switcher/locale-links.ts",
|
"filePath": "src/app/shared/layout/language-switcher/locale-links.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user