feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
ApplicationConfig,
|
||||
LOCALE_ID,
|
||||
isDevMode,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
} from '@angular/core';
|
||||
import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router';
|
||||
import type { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import localeNl from '@angular/common/locales/nl';
|
||||
import localeEn from '@angular/common/locales/en';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
||||
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||
import { SESSION_PORT } from '@shared/application/session.port';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
import { provideRouteFocus } from '@shared/layout/route-focus';
|
||||
import { provideUnloadFlush } from '@shared/application/pending-saves';
|
||||
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from '@shared/layout/site-header/nav-config';
|
||||
import { ADMIN_LINKS, NAV_ITEMS } from './shell/nav.config';
|
||||
|
||||
// Both locales' data so DatePipe/number pipes work for whichever bundle is active.
|
||||
registerLocaleData(localeNl);
|
||||
registerLocaleData(localeEn);
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(
|
||||
routes,
|
||||
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
|
||||
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
|
||||
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
|
||||
// animate: for the transition's duration Firefox's `::view-transition`
|
||||
// overlay swallows pointer events (Chrome sets pointer-events:none, so it
|
||||
// doesn't), which loses a click landing on it and makes the wizard's "next"
|
||||
// button need a second click. Skip the transition when the route is unchanged.
|
||||
withViewTransitions({
|
||||
onViewTransitionCreated: ({ transition, from, to }) => {
|
||||
// `from`/`to` are the ROOT snapshots (the shared shell), so descend to the
|
||||
// leaf before comparing — otherwise every navigation looks "same route".
|
||||
const leaf = (r: ActivatedRouteSnapshot) => {
|
||||
while (r.firstChild) r = r.firstChild;
|
||||
return r;
|
||||
};
|
||||
if (leaf(from).routeConfig === leaf(to).routeConfig) transition.skipTransition();
|
||||
},
|
||||
}),
|
||||
),
|
||||
// Dev-only: the ?scenario= toggle must never reach a production build, where
|
||||
// a query param could otherwise force errors on the live app.
|
||||
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),
|
||||
provideApiClient(),
|
||||
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
|
||||
// non-localized dev/source build leaves it undefined → fall back to 'nl'. (Was hardcoded
|
||||
// 'nl', which mis-formatted dates/numbers in the en bundle.)
|
||||
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
|
||||
provideRouteFocus(),
|
||||
provideUnloadFlush(),
|
||||
{ provide: HEADER_NAV_ITEMS, useValue: NAV_ITEMS },
|
||||
{ provide: HEADER_ADMIN_LINKS, useValue: ADMIN_LINKS },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { ShellComponent } from '@shared/layout/shell/shell.component';
|
||||
import { authGuard, capabilityGuard } from '@auth/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ShellComponent, // persistent header/footer; only children swap
|
||||
children: [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'login' },
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
canActivate: [authGuard],
|
||||
// TODO(create-ssp): stopgap landing page — point this at a real overview once you have one.
|
||||
loadComponent: () =>
|
||||
import('@behandeling/ui/behandeling.page').then((m) => m.BehandelingPage),
|
||||
},
|
||||
{
|
||||
path: 'beheer/stamdata',
|
||||
// Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default
|
||||
// unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the
|
||||
// StamdataAdmin gate — the guard just avoids loading a page that would 403.
|
||||
canActivate: [capabilityGuard('stamdata:edit')],
|
||||
loadComponent: () => import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage),
|
||||
},
|
||||
{
|
||||
path: 'beheer/audit',
|
||||
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
|
||||
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
|
||||
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: 'behandeling',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('@behandeling/ui/behandeling.page').then((m) => m.BehandelingPage),
|
||||
},
|
||||
{ path: '**', redirectTo: 'login' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
template: '<router-outlet />',
|
||||
})
|
||||
export class App {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Session } from '../domain/session';
|
||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||
|
||||
const STORAGE_KEY = 'session-v1';
|
||||
|
||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
||||
G2: validate the shape before trusting it. G1: the BSN is never persisted
|
||||
(see the effect below), so a restored session carries an empty one — it is
|
||||
unused after login; only `naam` is shown in the chrome. */
|
||||
function restore(): Session | null {
|
||||
try {
|
||||
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;
|
||||
} catch {
|
||||
return 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 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 {
|
||||
private digid = inject(DigidAdapter);
|
||||
private _session = signal<Session | null>(restore());
|
||||
|
||||
readonly session = this._session.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
/** Effectful command: authenticate, then store the session on success. */
|
||||
async login(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = await this.digid.authenticate(bsn);
|
||||
if (r.ok) this._session.set(r.value);
|
||||
return r;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this._session.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { SessionStore } from './application/session.store';
|
||||
import { authGuard, capabilityGuard } from './auth.guard';
|
||||
|
||||
type Opts = {
|
||||
authed: boolean;
|
||||
can?: (c: string) => boolean;
|
||||
whenReady?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function setup({ authed, can = () => false, whenReady = () => Promise.resolve() }: Opts) {
|
||||
const createUrlTree = vi.fn((cmds: string[]) => ({ tree: cmds }));
|
||||
const readySpy = vi.fn(whenReady);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: SessionStore, useValue: { isAuthenticated: () => authed } },
|
||||
{ provide: AccessStore, useValue: { whenReady: readySpy, can } },
|
||||
{ provide: Router, useValue: { createUrlTree } },
|
||||
],
|
||||
});
|
||||
return { createUrlTree, readySpy };
|
||||
}
|
||||
|
||||
// The guards ignore their (route, state) args; cast to call with none.
|
||||
const call = <T>(fn: unknown) => TestBed.runInInjectionContext(() => (fn as () => T)());
|
||||
|
||||
describe('authGuard', () => {
|
||||
it('allows an authenticated user', () => {
|
||||
setup({ authed: true });
|
||||
expect(call(authGuard)).toBe(true);
|
||||
});
|
||||
|
||||
it('redirects an anonymous user to /login', () => {
|
||||
const { createUrlTree } = setup({ authed: false });
|
||||
expect(call(authGuard)).toEqual({ tree: ['/login'] });
|
||||
expect(createUrlTree).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilityGuard', () => {
|
||||
const guard = () => capabilityGuard('stamdata:edit');
|
||||
|
||||
it('waits for /me, then allows an entitled admin', async () => {
|
||||
const { readySpy } = setup({ authed: true, can: (c) => c === 'stamdata:edit' });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toBe(true);
|
||||
expect(readySpy).toHaveBeenCalledOnce(); // it awaited caps before deciding
|
||||
});
|
||||
|
||||
it('sends an authenticated-but-unentitled user to /dashboard (not a login loop)', async () => {
|
||||
setup({ authed: true, can: () => false });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/dashboard'] });
|
||||
});
|
||||
|
||||
it('redirects an anonymous user to /login without waiting for caps', async () => {
|
||||
const { readySpy } = setup({ authed: false, can: () => true });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
|
||||
expect(readySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { SessionStore } from './application/session.store';
|
||||
|
||||
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const store = inject(SessionStore);
|
||||
const router = inject(Router);
|
||||
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
|
||||
};
|
||||
|
||||
/**
|
||||
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
|
||||
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
|
||||
*
|
||||
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
|
||||
* is still loading — it would deny an entitled admin and bounce them. We await
|
||||
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
|
||||
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
|
||||
* logged in, just not allowed here — no re-login loop). The backend re-enforces
|
||||
* regardless (403); this guard is the UX pre-gate.
|
||||
*/
|
||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
||||
return async () => {
|
||||
const session = inject(SessionStore);
|
||||
const access = inject(AccessStore);
|
||||
const router = inject(Router);
|
||||
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
|
||||
await access.whenReady();
|
||||
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Who is logged in. Framework-free domain type. */
|
||||
export interface Session {
|
||||
readonly bsn: string;
|
||||
readonly naam: string;
|
||||
}
|
||||
|
||||
export function isAuthenticated(s: Session | null): s is Session {
|
||||
return s !== null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Result, ok } from '@shared/kernel/fp';
|
||||
import { parseBsn } from '@shared/kernel/bsn';
|
||||
import { Session } from '../domain/session';
|
||||
|
||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DigidAdapter {
|
||||
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
||||
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
|
||||
// for a real OIDC redirect flow when there's an IdP.
|
||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = parseBsn(bsn);
|
||||
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Component, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
||||
@Component({
|
||||
selector: 'app-login-form',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
||||
template: `
|
||||
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
|
||||
<div class="form-header">
|
||||
<div class="form-action">
|
||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-form-field
|
||||
i18n-label="@@login.bsnLabel"
|
||||
label="BSN"
|
||||
fieldId="bsn"
|
||||
required
|
||||
i18n-description="@@login.bsnDescription"
|
||||
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="bsn"
|
||||
hasDescription
|
||||
[(ngModel)]="bsn"
|
||||
name="bsn"
|
||||
placeholder="123456782"
|
||||
/>
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
|
||||
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
|
||||
</app-form-field>
|
||||
|
||||
<app-button type="submit" variant="primary" i18n="@@login.submit"
|
||||
>Inloggen met DigiD</app-button
|
||||
>
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
export class LoginFormComponent {
|
||||
bsn = '';
|
||||
password = '';
|
||||
submitted = output<string>();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LoginFormComponent } from './login-form.component';
|
||||
|
||||
const meta: Meta<LoginFormComponent> = {
|
||||
title: 'Domein/Auth/Login Form',
|
||||
component: LoginFormComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LoginFormComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login-page',
|
||||
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
|
||||
template: `
|
||||
<app-page-shell
|
||||
i18n-heading="@@login.heading"
|
||||
heading="Inloggen"
|
||||
width="narrow"
|
||||
i18n-intro="@@login.intro"
|
||||
intro="Log in op uw persoonlijke BIG-register omgeving."
|
||||
>
|
||||
@if (error()) {
|
||||
<app-alert type="error">{{ error() }}</app-alert>
|
||||
}
|
||||
<app-login-form (submitted)="login($event)" />
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class LoginPage {
|
||||
private store = inject(SessionStore);
|
||||
private router = inject(Router);
|
||||
error = signal('');
|
||||
|
||||
async login(bsn: string) {
|
||||
const r = await this.store.login(bsn);
|
||||
if (r.ok) this.router.navigate(['/dashboard']);
|
||||
else this.error.set(r.error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
|
||||
/**
|
||||
* Scaffolded by `gen:context` (WP-44) — replace with the `behandeling` context's first
|
||||
* feature slice (the `new-feature` skill: domain first, then infrastructure/application, UI last).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-behandeling-page',
|
||||
imports: [PageShellComponent],
|
||||
template: `
|
||||
<app-page-shell [heading]="heading">
|
||||
<p>{{ intro }}</p>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class BehandelingPage {
|
||||
protected heading = $localize`:@@behandeling.landing.heading:Behandeling`;
|
||||
protected intro = $localize`:@@behandeling.landing.intro:Hier komt de eerste behandeling-functionaliteit.`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AdminLink, HeaderNavItem } from '@shared/layout/site-header/nav-config';
|
||||
|
||||
/** This app's primary nav — provided to the shared site header via HEADER_NAV_ITEMS
|
||||
(see app.config.ts). */
|
||||
export const NAV_ITEMS: readonly HeaderNavItem[] = [
|
||||
{ label: $localize`:@@header.nav.overzicht:Overzicht`, to: '/dashboard' },
|
||||
];
|
||||
|
||||
/** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS.
|
||||
No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from
|
||||
WP-61's bootstrap trim, not revisited by this migration. */
|
||||
export const ADMIN_LINKS: readonly AdminLink[] = [
|
||||
{
|
||||
label: $localize`:@@header.nav.stamdata:Stamdata`,
|
||||
description: $localize`:@@admin.link.stamdata.desc:Business-tabellen onderhouden`,
|
||||
to: '/beheer/stamdata',
|
||||
cap: 'stamdata:edit',
|
||||
},
|
||||
{
|
||||
label: $localize`:@@header.nav.audit:Auditlog`,
|
||||
description: $localize`:@@admin.link.audit.desc:Toegangs- en inzagebeslissingen bekijken`,
|
||||
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',
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user