From 04138b435ab3c0d4fb4f655d67ce02de0c2b7136 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Fri, 15 May 2026 16:05:50 +0200 Subject: [PATCH] feat(portal): /api/me/capabilities + cross-app menu links + real role label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/components/header/header.ts | 8 +- .../src/environments/environment.ts | 9 ++ apps/portal-bff/src/app/app.module.ts | 2 + apps/portal-bff/src/me/me.controller.spec.ts | 82 ++++++++++++ apps/portal-bff/src/me/me.controller.ts | 79 +++++++++++ apps/portal-bff/src/me/me.module.ts | 13 ++ .../src/app/components/header/header.html | 2 +- .../src/app/components/header/header.spec.ts | 54 +++++++- .../src/app/components/header/header.ts | 47 ++++--- .../src/app/components/sidebar/sidebar.html | 7 +- .../app/components/sidebar/sidebar.spec.ts | 124 +++++++++++++++--- .../src/app/components/sidebar/sidebar.ts | 24 ++++ .../src/app/services/capabilities.service.ts | 80 +++++++++++ .../src/environments/environment.ts | 10 ++ apps/portal-shell/src/locale/messages.fr.xlf | 16 ++- .../0009-auth-flow-oidc-pkce-msal-node.md | 15 ++- 16 files changed, 520 insertions(+), 52 deletions(-) create mode 100644 apps/portal-bff/src/me/me.controller.spec.ts create mode 100644 apps/portal-bff/src/me/me.controller.ts create mode 100644 apps/portal-bff/src/me/me.module.ts create mode 100644 apps/portal-shell/src/app/services/capabilities.service.ts diff --git a/apps/portal-admin/src/app/components/header/header.ts b/apps/portal-admin/src/app/components/header/header.ts index 9eaf2ea..bf2824c 100644 --- a/apps/portal-admin/src/app/components/header/header.ts +++ b/apps/portal-admin/src/app/components/header/header.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/c import { RouterLink } from '@angular/router'; import { AuthService, type CurrentUser } from 'feature-auth'; import { UserMenu, type UserMenuItem } from 'shared-ui'; +import { environment } from '../../../environments/environment'; /** * Admin-portal header. Deliberately leaner than the user-portal @@ -43,12 +44,13 @@ export class AdminHeader { }); // Static menu rows. Same shape as the portal-shell menu so a user - // who learned one finds the other familiar; the cross-app - // "Open Portal Shell" entry lands with PR 3 once the role + - // environment plumbing is in place. + // who learned one finds the other familiar. The "Open Portal + // Shell" entry is unconditional here — the user-portal is public, + // any admin can jump back to it. protected readonly userMenuItems: readonly UserMenuItem[] = [ { label: 'Profile', icon: 'user', routerLink: '/profile' }, { label: 'Settings', icon: 'settings', disabled: true, badge: 'Soon' }, + { label: 'Open Portal Shell', icon: 'home', href: environment.shellAppUrl }, ]; protected readonly signedInAsLabel = 'Signed in as'; diff --git a/apps/portal-admin/src/environments/environment.ts b/apps/portal-admin/src/environments/environment.ts index f7687c6..569f489 100644 --- a/apps/portal-admin/src/environments/environment.ts +++ b/apps/portal-admin/src/environments/environment.ts @@ -34,4 +34,13 @@ export const environment = { * OTLP/HTTP traces endpoint — same OTel collector as portal-shell. */ otlpEndpoint: 'http://localhost:4318/v1/traces', + + /** + * Origin of the sibling `portal-shell` SPA — drives the "Open + * Portal Shell" entry in the admin user menu. Symmetric with + * `portal-shell`'s `adminAppUrl`; per ADR-0020 the two SPAs live + * on distinct origins, so this is a raw cross-origin ``, + * not an Angular route. + */ + shellAppUrl: 'http://localhost:4200', }; diff --git a/apps/portal-bff/src/app/app.module.ts b/apps/portal-bff/src/app/app.module.ts index e746b2b..ae5e43c 100644 --- a/apps/portal-bff/src/app/app.module.ts +++ b/apps/portal-bff/src/app/app.module.ts @@ -8,6 +8,7 @@ import { AdminModule } from '../admin/admin.module'; import { AuditModule } from '../audit/audit.module'; import { AuthModule } from '../auth/auth.module'; import { DownstreamModule } from '../downstream/downstream.module'; +import { MeModule } from '../me/me.module'; import { RedisModule } from '../redis/redis.module'; import { SecurityModule } from '../security/security.module'; import { SessionModule } from '../session/session.module'; @@ -25,6 +26,7 @@ import { UsersModule } from '../users/users.module'; HealthModule, AdminModule, DownstreamModule, + MeModule, UsersModule, ], controllers: [AppController], diff --git a/apps/portal-bff/src/me/me.controller.spec.ts b/apps/portal-bff/src/me/me.controller.spec.ts new file mode 100644 index 0000000..df61731 --- /dev/null +++ b/apps/portal-bff/src/me/me.controller.spec.ts @@ -0,0 +1,82 @@ +import { UnauthorizedException } from '@nestjs/common'; +import type { Request } from 'express'; +import { MeController } from './me.controller'; + +function makeReq( + user: { + oid: string; + tid: string; + username: string; + displayName: string; + amr: readonly string[]; + roles: readonly string[]; + } | null, +): Request { + return { session: { user } } as unknown as Request; +} + +describe('MeController', () => { + const controller = new MeController(); + + it('returns canAccessAdmin: true when the session carries Portal.Admin', () => { + const result = controller.capabilities( + makeReq({ + oid: 'oid-1', + tid: 'tenant-1', + username: 'admin@apf.example', + displayName: 'Admin Smith', + amr: ['pwd', 'mfa'], + roles: ['Portal.Admin', 'Other.Role'], + }), + ); + expect(result).toEqual({ canAccessAdmin: true }); + }); + + it('returns canAccessAdmin: false when the session lacks Portal.Admin', () => { + const result = controller.capabilities( + makeReq({ + oid: 'oid-2', + tid: 'tenant-1', + username: 'jane.doe@apf.example', + displayName: 'Jane Doe', + amr: ['pwd'], + roles: ['Other.Role'], + }), + ); + expect(result).toEqual({ canAccessAdmin: false }); + }); + + it('returns canAccessAdmin: false when the session carries no roles', () => { + const result = controller.capabilities( + makeReq({ + oid: 'oid-3', + tid: 'tenant-1', + username: 'guest@apf.example', + displayName: 'Guest', + amr: [], + roles: [], + }), + ); + expect(result).toEqual({ canAccessAdmin: false }); + }); + + it('throws Unauthorized when no session is present', () => { + expect(() => controller.capabilities(makeReq(null))).toThrow(UnauthorizedException); + }); + + it('does NOT echo the raw roles array in the response (curated view contract)', () => { + const result = controller.capabilities( + makeReq({ + oid: 'oid-4', + tid: 'tenant-1', + username: 'admin@apf.example', + displayName: 'Admin', + amr: ['pwd', 'mfa'], + roles: ['Portal.Admin', 'Confidential.Role'], + }), + ); + expect(Object.keys(result)).toEqual(['canAccessAdmin']); + expect(JSON.stringify(result)).not.toContain('Confidential.Role'); + expect(JSON.stringify(result)).not.toContain('Portal.Admin'); + }); +}); diff --git a/apps/portal-bff/src/me/me.controller.ts b/apps/portal-bff/src/me/me.controller.ts new file mode 100644 index 0000000..de9f822 --- /dev/null +++ b/apps/portal-bff/src/me/me.controller.ts @@ -0,0 +1,79 @@ +import { Controller, Get, Req, UnauthorizedException } from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Request } from 'express'; + +/** + * Curated capabilities derived from the active user-portal session, + * keyed by the binary access decisions the SPA needs to drive its + * own UI. Deliberately *not* a passthrough of `session.user.roles`: + * ADR-0009 §"curated public view" keeps the raw `roles` claim + * server-side; the SPA renders gates and cross-app links from the + * derived booleans this view exposes. + * + * Add a key when the SPA needs a new conditional rendering signal. + * Never add a key that hands back information not already deducible + * from the BFF's own server-side authorization decisions — the + * authoritative gate stays on the BFF guards (`@RequireAdmin`, + * `@RequireMfa`, …). + */ +export interface CapabilitiesView { + /** + * True when the session's roles include the `Portal.Admin` Entra + * app role — the SPA uses this to surface the "Open Portal Admin" + * entry in the user menu. The actual `/api/admin/*` access is + * gated server-side by `AdminRoleGuard`; this flag is UX only. + */ + readonly canAccessAdmin: boolean; +} + +const ADMIN_ROLE = 'Portal.Admin'; + +/** + * `GET /api/me/capabilities` — projects the active session's roles + * onto a curated capabilities view consumed by `portal-shell`. The + * SPA calls this once per app boot (and on session refresh) to + * decide what to render: today, whether to expose the "Open Portal + * Admin" entry in the user menu; future keys land alongside as more + * conditional UI is required. + * + * Per ADR-0009 §"curated public view", the user-portal `/me` does + * NOT expose the raw `roles` claim — it stays server-side. This + * endpoint is the documented release-valve for binary UX hints, + * shaped so consumers can never reconstruct the raw role names. + * + * Mounted under `/api/me/*` rather than `/api/auth/*`: auth is the + * lifecycle (login, callback, /me identity, logout) — capabilities + * is a derived session view. Keeping the namespaces distinct gives + * the latter room to grow without bloating the auth surface. + * + * The path-routed session middleware in `main.ts` resolves this + * to the user-portal session (`portal_session`), so admin-only + * surfaces continue to be reached via `/api/admin/auth/me` for + * their identity payload and through the corresponding admin-side + * guards for authorization. + */ +@ApiTags('me') +@ApiCookieAuth('portal_session') +@Controller('me') +export class MeController { + @ApiOperation({ + summary: 'Curated capabilities derived from the active user-portal session', + }) + @Get('capabilities') + capabilities(@Req() req: Request): CapabilitiesView { + const user = req.session.user; + if (!user) { + // Same posture as `/api/auth/me`: 401 (rather than 200 with + // every-flag-false) so the SPA can distinguish "no session" + // from "session exists but no flags". Keeps the consumer code + // path identical between the two reads. + throw new UnauthorizedException({ + code: 'unauthenticated', + message: 'Unauthenticated', + }); + } + return { + canAccessAdmin: user.roles.includes(ADMIN_ROLE), + }; + } +} diff --git a/apps/portal-bff/src/me/me.module.ts b/apps/portal-bff/src/me/me.module.ts new file mode 100644 index 0000000..861d507 --- /dev/null +++ b/apps/portal-bff/src/me/me.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { MeController } from './me.controller'; + +/** + * `MeModule` — owns `/api/me/*`, the namespace of session-derived + * curated views consumed by `portal-shell`. Currently ships the + * capabilities endpoint per ADR-0009 amendment; further projections + * (preferences, locale resolution, etc.) land under the same prefix. + */ +@Module({ + controllers: [MeController], +}) +export class MeModule {} diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html index 65748f7..2c8631c 100644 --- a/apps/portal-shell/src/app/components/header/header.html +++ b/apps/portal-shell/src/app/components/header/header.html @@ -95,7 +95,7 @@ [displayName]="state.user.displayName" [username]="state.user.username" [initials]="initials()" - [items]="userMenuItems" + [items]="userMenuItems()" [signedInAsLabel]="signedInAsLabel" [signOutLabel]="signOutLabel" [triggerAriaLabel]="triggerAriaLabel(state.user.displayName)" diff --git a/apps/portal-shell/src/app/components/header/header.spec.ts b/apps/portal-shell/src/app/components/header/header.spec.ts index 6a37660..462db79 100644 --- a/apps/portal-shell/src/app/components/header/header.spec.ts +++ b/apps/portal-shell/src/app/components/header/header.spec.ts @@ -7,6 +7,7 @@ import { Header } from './header'; const BFF_BASE = 'http://bff.test/api'; const ME_URL = `${BFF_BASE}/auth/me`; +const CAPABILITIES_URL = `${BFF_BASE}/me/capabilities`; const USER: CurrentUser = { oid: 'user-oid', @@ -15,7 +16,10 @@ const USER: CurrentUser = { displayName: 'Jane Doe', }; -async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'error' | 'leave' }) { +async function setup(opts?: { + onBootstrap?: 'authenticated' | 'anonymous' | 'error' | 'leave'; + canAccessAdmin?: boolean; +}) { TestBed.configureTestingModule({ imports: [Header], providers: [ @@ -35,6 +39,14 @@ async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'err const req = httpCtrl.expectOne(ME_URL); if (intent === 'authenticated') { req.flush(USER); + // Multiple change-detection rounds + microtasks let Angular's + // signal scheduler dispatch the `effect()` in + // `CapabilitiesService` so its GET request is queued before + // we look it up below. + await flushPendingEffects(fixture); + httpCtrl.expectOne(CAPABILITIES_URL).flush({ + canAccessAdmin: opts?.canAccessAdmin ?? false, + }); } else if (intent === 'anonymous') { req.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' }); } else { @@ -42,11 +54,18 @@ async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'err } await Promise.resolve(); } - fixture.detectChanges(); - await fixture.whenStable(); + await flushPendingEffects(fixture); return { fixture, httpCtrl }; } +async function flushPendingEffects(fixture: ReturnType) { + for (let i = 0; i < 4; i++) { + fixture.detectChanges(); + await fixture.whenStable(); + await Promise.resolve(); + } +} + describe('Header', () => { beforeEach(() => { localStorage.clear(); @@ -151,6 +170,35 @@ describe('Header', () => { expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull(); }); + it('omits the "Open Portal Admin" entry when canAccessAdmin is false', async () => { + const { fixture } = await setup({ onBootstrap: 'authenticated', canAccessAdmin: false }); + const trigger = (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="user-menu"] button[aria-haspopup="menu"]', + ); + trigger?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + const items = Array.from( + document.body.querySelectorAll('.user-menu__panel .user-menu__item'), + ); + expect(items.some((el) => el.textContent?.includes('Open Portal Admin'))).toBe(false); + }); + + it('includes the "Open Portal Admin" entry pointing at the admin app when canAccessAdmin is true', async () => { + const { fixture } = await setup({ onBootstrap: 'authenticated', canAccessAdmin: true }); + const trigger = (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="user-menu"] button[aria-haspopup="menu"]', + ); + trigger?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + const adminLink = document.body.querySelector( + '.user-menu__panel a.user-menu__item[href^="http"]', + ); + expect(adminLink).not.toBeNull(); + expect(adminLink?.textContent).toContain('Open Portal Admin'); + }); + it('shows the error chip when /me fails with a non-401', async () => { const { fixture } = await setup({ onBootstrap: 'error' }); const root = fixture.nativeElement as HTMLElement; diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts index d6f3519..d06be96 100644 --- a/apps/portal-shell/src/app/components/header/header.ts +++ b/apps/portal-shell/src/app/components/header/header.ts @@ -3,6 +3,8 @@ import { RouterLink } from '@angular/router'; import { AuthService, type CurrentUser } from 'feature-auth'; import { Icon, UserMenu, type UserMenuItem } from 'shared-ui'; import { LayoutStateService } from 'shared-state'; +import { environment } from '../../../environments/environment'; +import { CapabilitiesService } from '../../services/capabilities.service'; import { ThemeSwitcher } from '../theme-switcher/theme-switcher'; /** @@ -35,6 +37,7 @@ import { ThemeSwitcher } from '../theme-switcher/theme-switcher'; export class Header { private readonly layout = inject(LayoutStateService); private readonly auth = inject(AuthService); + private readonly capabilities = inject(CapabilitiesService); protected readonly collapsed = this.layout.sidebarCollapsed; @@ -45,22 +48,34 @@ export class Header { return user ? initialsFor(user) : ''; }); - // Static menu rows for the authenticated user. Sign out is NOT in - // this list — it's the dedicated row at the bottom of the panel, - // emitted via the menu's `signOut` output. - protected readonly userMenuItems: readonly UserMenuItem[] = [ - { - label: $localize`:@@header.userMenu.profile:Profile`, - icon: 'user', - routerLink: '/profile', - }, - { - label: $localize`:@@header.userMenu.settings:Settings`, - icon: 'settings', - disabled: true, - badge: $localize`:@@common.badge.soon:Soon`, - }, - ]; + // Menu rows are recomputed when capabilities flip so the "Open + // Portal Admin" entry appears as soon as the BFF confirms admin + // access for the current session. Sign out is NOT in this list — + // it's the dedicated row at the bottom of the panel, emitted via + // the menu's `signOut` output. + protected readonly userMenuItems = computed(() => { + const items: UserMenuItem[] = [ + { + label: $localize`:@@header.userMenu.profile:Profile`, + icon: 'user', + routerLink: '/profile', + }, + { + label: $localize`:@@header.userMenu.settings:Settings`, + icon: 'settings', + disabled: true, + badge: $localize`:@@common.badge.soon:Soon`, + }, + ]; + if (this.capabilities.canAccessAdmin()) { + items.push({ + label: $localize`:@@header.userMenu.openAdmin:Open Portal Admin`, + icon: 'wrench', + href: environment.adminAppUrl, + }); + } + return items; + }); protected readonly signedInAsLabel = $localize`:@@header.userMenu.signedInAs:Signed in as`; protected readonly signOutLabel = $localize`:@@header.userMenu.signOut:Sign out`; diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.html b/apps/portal-shell/src/app/components/sidebar/sidebar.html index 257d22e..5cf10c3 100644 --- a/apps/portal-shell/src/app/components/sidebar/sidebar.html +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.html @@ -60,12 +60,11 @@ @if (!collapsed()) {

Role: - Anonymous{{ roleLabel() }}

} diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.spec.ts b/apps/portal-shell/src/app/components/sidebar/sidebar.spec.ts index 8d452ec..2f10657 100644 --- a/apps/portal-shell/src/app/components/sidebar/sidebar.spec.ts +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.spec.ts @@ -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) { + 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'); + }); + }); }); diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.ts b/apps/portal-shell/src/app/components/sidebar/sidebar.ts index b495204..d2b115c 100644 --- a/apps/portal-shell/src/app/components/sidebar/sidebar.ts +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.ts @@ -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` diff --git a/apps/portal-shell/src/app/services/capabilities.service.ts b/apps/portal-shell/src/app/services/capabilities.service.ts new file mode 100644 index 0000000..36eec6c --- /dev/null +++ b/apps/portal-shell/src/app/services/capabilities.service.ts @@ -0,0 +1,80 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Injectable, computed, effect, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { AUTH_BFF_BASE_URL, AuthService } from 'feature-auth'; + +/** + * Curated capabilities view served by the BFF at + * `GET /api/me/capabilities` (ADR-0009 amendment in this PR). + * Mirrors the BFF's `CapabilitiesView` interface 1:1. + */ +export interface Capabilities { + readonly canAccessAdmin: boolean; +} + +/** Anonymous default — used while auth state is loading or anonymous. */ +const ANONYMOUS_CAPABILITIES: Capabilities = { canAccessAdmin: false }; + +/** + * `CapabilitiesService` — fetches `/api/me/capabilities` whenever the + * `AuthService` flips into `authenticated`, exposes the resulting + * booleans as signals for the header / sidebar to read. + * + * Per ADR-0009 §"curated public view" the user-portal `/me` + * deliberately omits the raw `roles` claim; the SPA derives binary + * UX flags from this companion endpoint instead. Today the only + * flag is `canAccessAdmin` — drives the "Open Portal Admin" entry + * in the user menu and the "Role: Administrator" label in the + * sidebar widget. + * + * Anonymous traffic doesn't trigger a fetch (the BFF would 401); + * the signal stays at the all-false default. The service re-fetches + * automatically when sign-in completes thanks to the `effect` on + * `auth.currentUser`. + */ +@Injectable({ providedIn: 'root' }) +export class CapabilitiesService { + private readonly http = inject(HttpClient); + private readonly auth = inject(AuthService); + private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL); + + private readonly _capabilities = signal(ANONYMOUS_CAPABILITIES); + + readonly capabilities = this._capabilities.asReadonly(); + readonly canAccessAdmin = computed(() => this._capabilities().canAccessAdmin); + + constructor() { + // Reactive plumbing: whenever the authenticated user signal + // changes (initial bootstrap, post-login redirect return, sign- + // out), re-resolve the capabilities. The effect resets the + // payload to the anonymous default before fetching so a stale + // "yes admin" from a previous user session doesn't leak into + // the new one. + effect(() => { + const user = this.auth.currentUser(); + if (user) { + void this.refresh(); + } else { + this._capabilities.set(ANONYMOUS_CAPABILITIES); + } + }); + } + + async refresh(): Promise { + try { + const caps = await firstValueFrom( + this.http.get(`${this.bffBaseUrl}/me/capabilities`), + ); + this._capabilities.set(caps); + } catch (err) { + // 401 here means the session expired between the auth state + // bootstrap and our fetch — collapse to anonymous. Any other + // error is rare enough (network blip, BFF 500) that conservative + // "no extra UI" is the right default; the `AuthService`'s own + // 401 interceptor will re-bootstrap the state on the next call. + if (err instanceof HttpErrorResponse) { + this._capabilities.set(ANONYMOUS_CAPABILITIES); + } + } + } +} diff --git a/apps/portal-shell/src/environments/environment.ts b/apps/portal-shell/src/environments/environment.ts index 121f3dd..20234c6 100644 --- a/apps/portal-shell/src/environments/environment.ts +++ b/apps/portal-shell/src/environments/environment.ts @@ -40,4 +40,14 @@ export const environment = { * `infra/local/otel-collector.yaml`. */ otlpEndpoint: 'http://localhost:4318/v1/traces', + + /** + * Origin of the sibling `portal-admin` SPA — driver of the + * cross-app "Open Portal Admin" entry in the user menu (gated on + * `CapabilitiesService.canAccessAdmin`). Per ADR-0020 the two + * SPAs live on distinct origins / cookies / sessions, so this is + * a raw cross-origin `
` jump, not an Angular route. + * Per-env siblings override the host. + */ + adminAppUrl: 'http://localhost:4300', }; diff --git a/apps/portal-shell/src/locale/messages.fr.xlf b/apps/portal-shell/src/locale/messages.fr.xlf index 23e0e41..3f2a71e 100644 --- a/apps/portal-shell/src/locale/messages.fr.xlf +++ b/apps/portal-shell/src/locale/messages.fr.xlf @@ -80,6 +80,10 @@ Sign out Se déconnecter + + Open Portal Admin + Ouvrir Administration APF Portal + User menu — signed in as Menu utilisateur — connecté en tant que @@ -150,9 +154,17 @@ Anonymous Anonyme + + Administrator + Administrateur + + + User + Utilisateur + - Current role: anonymous (signed-out) - Rôle actuel : anonyme (non connecté) + Current role: + Rôle actuel : Role: diff --git a/docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md b/docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md index 147fbe1..6700cfe 100644 --- a/docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md +++ b/docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md @@ -120,15 +120,18 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi **Routes.** All authentication endpoints live under the `/auth` prefix on the BFF: -| Method | Path | Purpose | -| ------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters | -| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) | -| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL | -| `GET` | `/auth/me` | returns the current user's id, audience, and a curated subset of claims for the SPA to display (no tokens) | +| Method | Path | Purpose | +| ------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters | +| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) | +| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL | +| `GET` | `/auth/me` | returns the current user's id, audience, and a curated subset of claims for the SPA to display (no tokens, no raw `roles` claim — see "curated public view" below) | +| `GET` | `/me/capabilities` | curated capabilities view derived from the session (currently `{ canAccessAdmin: boolean }`); drives binary SPA gates without exposing the raw `roles` claim — see "curated public view" below | **Authorization layer.** A NestJS `AuthGuard` checks for a valid session and rejects with 401 otherwise. A `@CurrentUser()` decorator extracts `{ id, audience, claims }` from the request scope. Every controller (other than `/auth/*` itself, `/health`, etc.) is protected by `AuthGuard` by default — the framework default is "denied", explicit allow-listing is required. +**Curated public view.** 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, consumed by the BFF's own guards (`@RequireAdmin`, downstream-API gates, etc.). The SPA derives binary UX hints from a dedicated companion endpoint, `GET /me/capabilities`, which returns a curated structure (today `{ canAccessAdmin: boolean }`; further keys land as conditional UI requires). The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles (auditor, redactor, …) does not widen the SPA-side surface. The authoritative gate remains BFF-side via `@RequireAdmin` & co.; capabilities is UX-only. The admin-portal `/api/admin/auth/me` exposes `roles` explicitly because it _is_ the admin surface — see ADR-0020. + **Configuration.** All Entra-specific values come from environment variables — no tenant ID, client ID, or secret in source. The BFF refuses to start if any required variable is missing. | Variable | Purpose |