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:
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user