feat(portal): /api/me/capabilities + cross-app menu links + real role label #151
Reference in New Issue
Block a user
Delete Branch "feat/me-capabilities-and-cross-app-links"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.
UserMenudropdown + integration./profilepages on both apps.GET /api/me/capabilities+ real sidebar role label + cross-app menu entries.What lands
BFF —
GET /api/me/capabilitiesNew
MeModulewiring a single endpoint:Resolved against the user-portal session (
portal_session— the path-routed session middleware inmain.tsalready 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 rawrolesarray.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 routes table grows a row for
/me/capabilities.Portal-shell — capabilities-driven UI
New service
CapabilitiesService(app-local, not infeature-auth— the admin app has its own roles channel via/api/admin/auth/meand would never use this). Signals:capabilities,canAccessAdmin. FiresGET /me/capabilitiesreactively via aneffectthat watchesauth.currentUser(). Anonymous sessions short-circuit to the all-false default without a fetch — the BFF would 401 anyway.Header (
header.ts) —userMenuItemsis now acomputed. Profile + Settings stay unconditional; "Open Portal Admin" appears only whencanAccessAdmin()flips true, withhref = 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 hardcodedRole: Anonymouswidget is replaced by a derived computed:Anonymouswhen no session.AdministratorwhencanAccessAdmin()is true.Userotherwise (signed-in, no admin).The aria-label gains a
roleplaceholder so screen readers hear the live value.Portal-admin — symmetric cross-app entry
header.ts) — adds an unconditionalOpen Portal Shellrow pointing atenvironment.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
adminAppUrlandshellAppUrladded to the respectiveenvironment.tsfiles (dev defaults::4300for admin,:4200for shell). Per-env siblings (staging / prod) will override the host once they exist, per ADR-0018.i18n
header.userMenu.openAdminsidebar.role.administratorsidebar.role.usersidebar.role.aria{role}placeholderAdmin-side strings stay in English source per ADR-0020.
Notes for the reviewer
CapabilitiesServicein the app, not infeature-auth? Onlyportal-shellwill ever call/api/me/capabilities— the admin SPA hits/api/admin/auth/mewhich already returnsroles. Putting the service infeature-authwould publish a tree-shakableprovidedIn: 'root'injectable that ships in both bundles. Keeping it app-local makes the boundary explicit.computedforuserMenuItemsrather than mutating an array in aneffect? Signals + computed = single source of truth. The sharedUserMenure-renders automatically when the items list changes (whenever capabilities flips). Less ceremony than maintaining aWritableSignal<UserMenuItem[]>.flushPendingEffectstest helper? Zoneless apps rely on the signals scheduler to dispatcheffect()callbacks via micro-task scheduling.fixture.detectChanges() + whenStable()once is not enough: the chain ismeReq.flush()→_state.set()→ effect scheduled → effect fires →http.get()queued. The helper loops 4× to give the scheduler enough rounds to settle beforeexpectOne(CAPABILITIES_URL)looks up the request.environment.tsis 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 inproject.json.Test plan
pnpm nx test portal-bff— 401 specs pass (was 396, +5 forMeController).pnpm nx test portal-shell— 40 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-strictportal-shell:build:production.Portal.Admin-assigned account):Role: Administrator, user menu listsOpen Portal Adminat the bottom (right above the Sign-out separator), clicking the link openslocalhost:4300.Open Portal Shell, clicking openslocalhost:4200.Role: User,Open Portal Adminis absent.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
itemsAPI without re-shaping the component.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.