feat(portal): /api/me/capabilities + cross-app menu links + real role label (#151)
CI / scan (push) Successful in 2m13s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m48s
CI / a11y (push) Successful in 1m53s
CI / perf (push) Successful in 3m46s

## Summary

PR 3 of 3 — final piece of the user-menu / profile / cross-app chantier. Closes the loop with the BFF capabilities endpoint, symmetric cross-app entries in both user menus, and a real role label in place of the hardcoded "Anonymous" widget on the portal-shell sidebar.

| PR | Périmètre |
| --- | --- |
| PR 1  | Shared `UserMenu` dropdown + integration. |
| PR 2  | `/profile` pages on both apps. |
| **PR 3 (this one)** | `GET /api/me/capabilities` + real sidebar role label + cross-app menu entries. |

## What lands

### BFF — `GET /api/me/capabilities`

New [`MeModule`](apps/portal-bff/src/me/me.module.ts) wiring a single endpoint:

```ts
GET /api/me/capabilities → { canAccessAdmin: boolean }
```

Resolved against the user-portal session ([`portal_session`](apps/portal-bff/src/me/me.controller.ts) — the path-routed session middleware in `main.ts` already maps `/api/me/*` to that session). Returns 401 if no session is present, consistent with `/api/auth/me`. 5 specs cover the four state combinations + a regression-fence asserting the curated view never leaks the raw `roles` array.

### ADR-0009 amendment

[`docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md`](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) — new **"Curated public view"** section codifies the design stance the user picked when we agreed the staging:

> The `/auth/me` payload exposes a deliberately narrow projection of the session: `oid`, `tid`, `username`, `displayName`. **The raw `roles` claim is _not_ part of `/auth/me`** — it stays server-side […]. The SPA derives binary UX hints from a dedicated companion endpoint […]. The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles […] does not widen the SPA-side surface.

The routes table grows a row for `/me/capabilities`.

### Portal-shell — capabilities-driven UI

- **New service** [`CapabilitiesService`](apps/portal-shell/src/app/services/capabilities.service.ts) (app-local, not in `feature-auth` — the admin app has its own roles channel via `/api/admin/auth/me` and would never use this). Signals: `capabilities`, `canAccessAdmin`. Fires `GET /me/capabilities` reactively via an `effect` that watches `auth.currentUser()`. Anonymous sessions short-circuit to the all-false default without a fetch — the BFF would 401 anyway.
- **Header** ([`header.ts`](apps/portal-shell/src/app/components/header/header.ts)) — `userMenuItems` is now a `computed`. Profile + Settings stay unconditional; **"Open Portal Admin"** appears only when `canAccessAdmin()` flips true, with `href = environment.adminAppUrl`. Per ADR-0020 the two SPAs live on distinct origins, so this is a raw cross-origin anchor, not a routerLink.
- **Sidebar** ([`sidebar.ts`](apps/portal-shell/src/app/components/sidebar/sidebar.ts) + [`sidebar.html`](apps/portal-shell/src/app/components/sidebar/sidebar.html)) — the hardcoded `Role: Anonymous` widget is replaced by a derived computed:
  - `Anonymous` when no session.
  - `Administrator` when `canAccessAdmin()` is true.
  - `User` otherwise (signed-in, no admin).

  The aria-label gains a `role` placeholder so screen readers hear the live value.

### Portal-admin — symmetric cross-app entry

- **Header** ([`header.ts`](apps/portal-admin/src/app/components/header/header.ts)) — adds an unconditional `Open Portal Shell` row pointing at `environment.shellAppUrl`. Anyone able to reach portal-admin can reach portal-shell, so no capabilities check needed; admins always benefit from a one-click jump back to the end-user surface.

### Environments

`adminAppUrl` and `shellAppUrl` added to the respective [`environment.ts`](apps/portal-shell/src/environments/environment.ts) files (dev defaults: `:4300` for admin, `:4200` for shell). Per-env siblings (staging / prod) will override the host once they exist, per ADR-0018.

### i18n

| Key | EN source | FR target |
| --- | --- | --- |
| `header.userMenu.openAdmin` | Open Portal Admin | Ouvrir Administration APF Portal |
| `sidebar.role.administrator` | Administrator | Administrateur |
| `sidebar.role.user` | User | Utilisateur |
| `sidebar.role.aria` | reshaped with `{role}` placeholder | reshaped likewise |

Admin-side strings stay in English source per ADR-0020.

## Notes for the reviewer

- **Why `CapabilitiesService` in the app, not in `feature-auth`?** Only `portal-shell` will ever call `/api/me/capabilities` — the admin SPA hits `/api/admin/auth/me` which already returns `roles`. Putting the service in `feature-auth` would publish a tree-shakable `providedIn: 'root'` injectable that ships in both bundles. Keeping it app-local makes the boundary explicit.
- **Why a `computed` for `userMenuItems` rather than mutating an array in an `effect`?** Signals + computed = single source of truth. The shared `UserMenu` re-renders automatically when the items list changes (whenever capabilities flips). Less ceremony than maintaining a `WritableSignal<UserMenuItem[]>`.
- **Why the `flushPendingEffects` test helper?** Zoneless apps rely on the signals scheduler to dispatch `effect()` callbacks via micro-task scheduling. `fixture.detectChanges() + whenStable()` once is not enough: the chain is `meReq.flush()` → `_state.set()` → effect scheduled → effect fires → `http.get()` queued. The helper loops 4× to give the scheduler enough rounds to settle before `expectOne(CAPABILITIES_URL)` looks up the request.
- **Why no test for the dev URL values?** `environment.ts` is config that gets swapped at build time per ADR-0018; the values themselves are environmental. Asserting the dev value in a test would lock in a port (4200/4300) that's separately configured in `project.json`.

## Test plan

- [x] `pnpm nx test portal-bff` — **401 specs pass** (was 396, +5 for `MeController`).
- [x] `pnpm nx test portal-shell` — **40 specs pass** (was 35, +5: 3 sidebar role-label + 2 header admin-link).
- [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — 18/18 tasks green, including the i18n-strict `portal-shell:build:production`.
- [ ] Manual smoke (with a `Portal.Admin`-assigned account):
  - Sign in on portal-shell → sidebar reads `Role: Administrator`, user menu lists `Open Portal Admin` at the bottom (right above the Sign-out separator), clicking the link opens `localhost:4300`.
  - Sign in on portal-admin → user menu lists `Open Portal Shell`, clicking opens `localhost:4200`.
  - Sign in on portal-shell with a non-admin account → sidebar reads `Role: User`, `Open Portal Admin` is absent.
  - Sign out → sidebar reads `Role: Anonymous`, the menu collapses to its anonymous-state Sign-in button.

## What's next

Chantier closed. The user-menu shape is now stable; further entries (notifications inbox, theme override, locale switcher inside the menu rather than the footer) plug into the existing `items` API without re-shaping the component.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #151
This commit was merged in pull request #151.
This commit is contained in:
2026-05-15 16:11:26 +02:00
parent 1160771061
commit ee51efb688
16 changed files with 520 additions and 52 deletions
@@ -60,12 +60,11 @@
@if (!collapsed()) {
<p
class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
i18n-aria-label="@@sidebar.role.aria"
aria-label="Current role: anonymous (signed-out)"
[attr.aria-label]="roleAriaLabel()"
>
<ng-container i18n="@@sidebar.role.label">Role:</ng-container>
<span class="font-medium text-gray-700 dark:text-gray-200" i18n="@@sidebar.role.anonymous"
>Anonymous</span
<span class="font-medium text-gray-700 dark:text-gray-200" data-testid="sidebar-role"
>{{ roleLabel() }}</span
>
</p>
}
@@ -1,19 +1,72 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AUTH_BFF_BASE_URL, type CurrentUser } from 'feature-auth';
import { Sidebar } from './sidebar';
describe('Sidebar', () => {
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [Sidebar],
providers: [provideRouter([])],
}).compileComponents();
const BFF_BASE = 'http://bff.test/api';
const ME_URL = `${BFF_BASE}/auth/me`;
const CAPABILITIES_URL = `${BFF_BASE}/me/capabilities`;
interface SetupOpts {
user?: CurrentUser | null;
canAccessAdmin?: boolean;
}
async function setup(opts: SetupOpts = {}) {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [Sidebar],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
],
});
const fixture = TestBed.createComponent(Sidebar);
const http = TestBed.inject(HttpTestingController);
// AuthService fires `GET /auth/me` from its constructor; flush
// synchronously so the auth-state signal settles before any
// template assertion. `CapabilitiesService` then fires
// `GET /me/capabilities` via its `effect` once `currentUser`
// flips — running detectChanges + whenStable gives Angular's
// scheduler a chance to dispatch the effect before we look up
// the second request.
const meReq = http.expectOne(ME_URL);
if (opts.user) {
meReq.flush(opts.user);
// Two microtask rounds: the first lets `AuthService.refresh()`
// resume past `await firstValueFrom(...)` and call
// `this._state.set(...)`; the second lets `CapabilitiesService`'s
// effect dispatch and queue its own GET. detectChanges in
// between forces signal-driven effect scheduling under
// zoneless change detection.
await flushPendingEffects(fixture);
const capsReq = http.expectOne(CAPABILITIES_URL);
capsReq.flush({ canAccessAdmin: opts.canAccessAdmin ?? false });
} else {
meReq.flush({}, { status: 401, statusText: 'Unauthorized' });
}
await flushPendingEffects(fixture);
return { fixture, http };
}
async function flushPendingEffects(fixture: ReturnType<typeof TestBed.createComponent>) {
for (let i = 0; i < 4; i++) {
fixture.detectChanges();
await fixture.whenStable();
await Promise.resolve();
}
}
describe('Sidebar', () => {
beforeEach(() => localStorage.clear());
it('renders the three v1 menu groups', async () => {
const fixture = TestBed.createComponent(Sidebar);
await fixture.whenStable();
const { fixture } = await setup();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('General');
expect(text).toContain('Establishments');
@@ -21,17 +74,14 @@ describe('Sidebar', () => {
});
it('does not render the accessibility links (now lives in the footer)', async () => {
const fixture = TestBed.createComponent(Sidebar);
await fixture.whenStable();
const { fixture } = await setup();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('a[href="/accessibility"]')).toBeNull();
expect(root.querySelector('a[href="/accessibilite"]')).toBeNull();
});
it('starts expanded and collapses on toggle, persisting to localStorage', async () => {
const fixture = TestBed.createComponent(Sidebar);
fixture.detectChanges();
await fixture.whenStable();
const { fixture } = await setup();
const root = fixture.nativeElement as HTMLElement;
const aside = root.querySelector('aside');
expect(aside?.classList.contains('sidebar--collapsed')).toBe(false);
@@ -48,10 +98,50 @@ describe('Sidebar', () => {
it('restores the collapsed state from localStorage on init', async () => {
localStorage.setItem('portal-shell:sidebar-collapsed', '1');
const fixture = TestBed.createComponent(Sidebar);
fixture.detectChanges();
await fixture.whenStable();
const { fixture } = await setup();
const aside = (fixture.nativeElement as HTMLElement).querySelector('aside');
expect(aside?.classList.contains('sidebar--collapsed')).toBe(true);
});
describe('role label', () => {
it('renders "Anonymous" when the session is missing', async () => {
const { fixture } = await setup();
const label = (fixture.nativeElement as HTMLElement).querySelector(
'[data-testid="sidebar-role"]',
);
expect(label?.textContent?.trim()).toBe('Anonymous');
});
it('renders "User" when the session is authenticated without admin', async () => {
const { fixture } = await setup({
user: {
oid: 'user-oid',
tid: 'tenant',
username: 'jane.doe@apf.example',
displayName: 'Jane Doe',
},
canAccessAdmin: false,
});
const label = (fixture.nativeElement as HTMLElement).querySelector(
'[data-testid="sidebar-role"]',
);
expect(label?.textContent?.trim()).toBe('User');
});
it('renders "Administrator" when capabilities flag canAccessAdmin', async () => {
const { fixture } = await setup({
user: {
oid: 'admin-oid',
tid: 'tenant',
username: 'admin@apf.example',
displayName: 'Admin Smith',
},
canAccessAdmin: true,
});
const label = (fixture.nativeElement as HTMLElement).querySelector(
'[data-testid="sidebar-role"]',
);
expect(label?.textContent?.trim()).toBe('Administrator');
});
});
});
@@ -1,7 +1,9 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { AuthService } from 'feature-auth';
import { Icon, type IconName } from 'shared-ui';
import { LayoutStateService } from 'shared-state';
import { CapabilitiesService } from '../../services/capabilities.service';
/**
* Primary side navigation.
@@ -80,10 +82,32 @@ const MENU: readonly MenuGroup[] = [
})
export class Sidebar {
private readonly layout = inject(LayoutStateService);
private readonly auth = inject(AuthService);
private readonly capabilities = inject(CapabilitiesService);
protected readonly menu = MENU;
protected readonly collapsed = this.layout.sidebarCollapsed;
// Role label rendered next to the toggle, three states:
// - "Anonymous" when no session
// - "Administrator" when the session carries `Portal.Admin`
// - "User" otherwise (signed-in non-admin)
// Derived from `AuthService` + `CapabilitiesService`; the latter
// populates `canAccessAdmin` from `GET /api/me/capabilities` so the
// SPA never sees the raw `roles` claim per ADR-0009.
protected readonly roleLabel = computed(() => {
if (!this.auth.currentUser()) {
return $localize`:@@sidebar.role.anonymous:Anonymous`;
}
return this.capabilities.canAccessAdmin()
? $localize`:@@sidebar.role.administrator:Administrator`
: $localize`:@@sidebar.role.user:User`;
});
protected readonly roleAriaLabel = computed(
() => $localize`:@@sidebar.role.aria:Current role: ${this.roleLabel()}:role:`,
);
protected readonly toggleAriaLabel = computed(() =>
this.collapsed()
? $localize`:@@sidebar.toggle.aria.expand:Expand sidebar`