fix(auth): make admin pages reachable — async capability guard + sticky dev role + nav
CI / frontend (push) Successful in 1m44s
CI / storybook-a11y (push) Failing after 4m28s
CI / backend (push) Successful in 1m28s
CI / e2e (push) Successful in 2m49s
CI / codeql (csharp) (push) Failing after 2m8s
CI / codeql (javascript-typescript) (push) Failing after 1m30s
CI / api-client-drift (push) Successful in 2m6s

The admin pages (/beheer/stamdata, /brief/huisstijl) were unreachable in the browser,
for three compounding reasons — all fixed here:

- **Guard raced /me.** capabilityGuard read can() synchronously while /me was still
  loading, so it denied even an entitled admin (deny-by-default) and bounced to /login.
  It's now async: awaits AccessStore.whenReady() (new — resolves once /me settles), then
  allows if entitled; an authenticated-but-unentitled user goes to /dashboard, anonymous
  to /login. + auth.guard.spec (the missing test that let this ship).
- **Dev role wasn't sticky.** currentRole() read ?role= from the URL on every request,
  but login/nav drop the param, silently reverting admin→drafter mid-session and 403-ing
  the admin endpoints. It now persists the role per-tab (sessionStorage), so every
  role-aware request keeps it. Dev-only (the interceptor is wired only under isDevMode).
- **No way in.** Added capability-gated Huisstijl + Stamdata links to the header (shown
  only when /me grants the cap); injecting AccessStore there also warms /me early. New
  en translations for the two labels; site-header story stubs AccessStore (+ AsAdmin
  variant) so it needs no HTTP.

Verified live: with ?role=admin the header shows both links, clicking Stamdata loads the
grid (GET /api/v1/stamdata → 200, was 403→redirect); a non-admin sees no link. Full
`npm run ci` green (310 tests); site-header stories pass axe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-21 21:03:32 +02:00
co-authored by Claude Opus 4.8
parent c0834cdbce
commit f7196768ea
10 changed files with 1831 additions and 1503 deletions
@@ -1,12 +1,17 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { roleInterceptor } from './role.interceptor';
// currentRole() reads window.location; pin it so the test is about routing, not the shim.
vi.mock('./role', () => ({ currentRole: () => 'admin' }));
// currentRole() reads window.location.search; set it via the real URL rather than
// vi.mock (the Angular unit-test system forbids mocking relative imports).
beforeEach(() => window.history.replaceState({}, '', '/?role=admin'));
afterEach(() => {
window.history.replaceState({}, '', '/');
sessionStorage.clear(); // currentRole() now persists the dev role; don't leak across tests
});
// Minimal stand-in for HttpRequest — the interceptor only reads `url` and calls
// `clone({ setHeaders })`. Avoids importing @angular/common/http at runtime (its XHR
// chunk needs the JIT compiler under vitest).
// `clone({ setHeaders })`. Avoids importing @angular/common/http (its XHR chunk needs
// the JIT compiler under vitest).
function fakeReq(url: string) {
const make = (headers: Map<string, string>) => ({
url,
+23 -8
View File
@@ -3,14 +3,29 @@ import { Role } from '@shared/domain/role';
/**
* Dev-only role stand-in (the reading MECHANISM; the `Role` type is domain). This
* POC has one faked self-service user and no real identities, so the two-person
* letter workflow (drafter vs approver) is driven by a `?role=` query param —
* exactly the pattern of the `?scenario=` toggle. The backend receives it as an
* `X-Role` header (see role.interceptor), resolves it into a `Principal`
* server-side, and is the sole authority on what that principal may do (PRD-0002
* phase P1, `Authz.Can`) — the FE only renders the resulting decision flags, it no
* longer derives permission from this value itself.
* letter workflow (drafter vs approver) plus admin is driven by a `?role=` query
* param. The backend receives it as an `X-Role` header (see role.interceptor),
* resolves it into a `Principal` server-side, and is the sole authority on what that
* principal may do (PRD-0002 phase P1, `Authz.Can`) — the FE only renders the
* resulting decision flags, it no longer derives permission from this value itself.
*
* **Sticky within the tab (sessionStorage):** the interceptor reads this per request,
* but navigation drops the query param (login redirects to /dashboard, RouterLinks
* don't carry it), which would silently revert an admin to drafter mid-session and
* 403 the admin endpoints. So a `?role=` seen in the URL is remembered for the tab;
* later requests use the remembered value. Set `?role=drafter` (or a fresh tab) to
* reset. Dev-only — the interceptor itself is only wired under `isDevMode()`.
*/
const STORAGE_KEY = 'dev-role';
const isRole = (v: string | null): v is Role =>
v === 'drafter' || v === 'approver' || v === 'admin';
export function currentRole(): Role {
const role = new URLSearchParams(window.location.search).get('role');
return role === 'approver' || role === 'admin' ? role : 'drafter';
const fromUrl = new URLSearchParams(window.location.search).get('role');
if (isRole(fromUrl)) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
return fromUrl;
}
const stored = sessionStorage.getItem(STORAGE_KEY);
return isRole(stored) ? stored : 'drafter';
}