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
@@ -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',
];