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
+62
View File
@@ -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();
});
});
+12 -9
View File
@@ -13,19 +13,22 @@ export const authGuard: CanActivateFn = () => {
/**
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
* redirect. No route in this app currently needs a capability gate — brief's
* canApprove/canReject/canSend are per-action, not per-page (both actors land on
* the same `/brief` page and see different actions) — so this exists as the
* available building block for the day a route-level gate is needed, e.g. a future
* approver-only page.
* 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 () => {
return async () => {
const session = inject(SessionStore);
const access = inject(AccessStore);
const router = inject(Router);
return session.isAuthenticated() && access.can(capability)
? true
: router.createUrlTree(['/login']);
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
await access.whenReady();
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
};
}