feat(admin): runtime feature flags (catalog-in-code, admin toggle, FE+backend)

Catalog declared in code (Domain/Features/FeatureFlags.cs, build-validated), on/off state
persisted in SQLite (FeatureFlagStore + migration). GET /flags (drives FE gating) + admin
PUT /admin/flags/{key} (new flags:manage capability + FlagsAdmin gate). Enforced end-to-end:
the `inschrijving-open` flag hides the Inschrijven nav item + dashboard action (FE) AND makes
POST /applications for a registratie 403 when off (backend). FE FeatureFlagStore mirrors
AccessStore (enabled() deny-by-default); admin toggle page at /beheer/functies in ADMIN_LINKS.
+4 backend tests, /me cap-list updated, client regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 22:29:48 +02:00
co-authored by Claude Opus 4.8
parent ed264be714
commit 67802c68b4
28 changed files with 1154 additions and 52 deletions
@@ -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();
}
}
}
+2 -1
View File
@@ -8,4 +8,5 @@ export type Capability =
| 'brief:send'
| 'orgtemplate:edit'
| 'stamdata:edit'
| 'cases:manage';
| 'cases:manage'
| 'flags:manage';
+9
View File
@@ -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',
];
+6
View File
@@ -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',
},
];
@@ -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)));