feat(portal): /api/me/capabilities + cross-app menu links + real role label #151

Merged
julien merged 1 commits from feat/me-capabilities-and-cross-app-links into main 2026-05-15 16:11:27 +02:00
Owner

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 wiring a single endpoint:

GET /api/me/capabilities  { canAccessAdmin: boolean }

Resolved against the user-portal session (portal_session — 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 — 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 (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) — 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 + 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) — 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 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

  • pnpm nx test portal-bff401 specs pass (was 396, +5 for MeController).
  • pnpm nx test portal-shell40 specs pass (was 35, +5: 3 sidebar role-label + 2 header admin-link).
  • 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.

## 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`. - [x] 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.
julien added 1 commit 2026-05-15 16:11:11 +02:00
feat(portal): /api/me/capabilities + cross-app menu links + real role label
CI / scan (pull_request) Successful in 4m4s
CI / commits (pull_request) Successful in 4m4s
CI / check (pull_request) Successful in 4m59s
CI / a11y (pull_request) Successful in 1m31s
CI / perf (pull_request) Successful in 5m25s
04138b435a
PR 3 of 3 — final piece of the user-menu / profile / cross-app
chantier.

BFF
  * New `MeModule` ships `GET /api/me/capabilities`, a curated view
    derived from the active user-portal session. Returns
    `{ canAccessAdmin: boolean }` today; the shape is deliberately
    binary so the SPA cannot reconstruct the raw `roles` claim. 401
    on missing session, consistent posture with `/auth/me`.
  * ADR-0009 amended: new "Curated public view" section codifies
    the "no raw roles on `/auth/me`, capabilities is the release
    valve" stance; the routes table grows a row for the endpoint.

portal-shell
  * New `CapabilitiesService` (app-local) calls the BFF on auth-state
    flip and exposes `canAccessAdmin` as a signal. Anonymous
    sessions short-circuit to the all-false default without firing
    a request.
  * Header's `userMenuItems` is now a `computed` — the "Open Portal
    Admin" entry appears in the dropdown only when
    `canAccessAdmin()` is true, pointing at
    `environment.adminAppUrl`.
  * Sidebar role widget reads the auth + capabilities signals to
    render one of three labels ("Anonymous" / "User" /
    "Administrator") in place of the hardcoded "Anonymous".

portal-admin
  * Symmetric: unconditional "Open Portal Shell" entry in the admin
    user menu, pointing at `environment.shellAppUrl`. Anyone who
    can reach portal-admin can reach portal-shell, so no
    capabilities check needed.

i18n
  * `header.userMenu.openAdmin`, `sidebar.role.{administrator,user}`,
    `sidebar.role.aria` reshaped to take the role as a placeholder.
julien merged commit ee51efb688 into main 2026-05-15 16:11:27 +02:00
julien deleted branch feat/me-capabilities-and-cross-app-links 2026-05-15 16:11:31 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#151