From c49a807966ba3f0fcfaf42b57d8d6debeaae6d85 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sat, 16 May 2026 03:05:22 +0200 Subject: [PATCH] feat(shared-ui): move the role label from the portal-shell sidebar into the user menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Role: Anonymous / User / Administrator" widget at the bottom of the portal-shell sidebar always struck the same audience as the user menu — it told the reader who they were and what they could do. With the menu now carrying the full identity card (displayName, username, role-curated actions like "Open Portal Admin"), keeping the role in the sidebar duplicates the surface and surfaces the label on anonymous traffic where it carries no real information ("Role: Anonymous" was a verbose synonym for "Sign in"). This PR moves the role chip into the user-menu panel header, where it sits between the username and the menu rows. Hidden on anonymous traffic by construction — the `` only renders for the authenticated case in both apps' headers. Shape: * `UserMenu` (shared-ui) gains an **optional `role` input**. When set, a small brand-tinted pill renders inside the panel header below the username (`data-testid="user-menu-role"`). When undefined, the entire chip is omitted — keeps backwards compat with any future caller that doesn't carry a role concept. * `portal-shell`'s header computes `roleLabel` from the existing `CapabilitiesService.canAccessAdmin` signal (the same logic that previously lived in the sidebar), passing the localised string through the new input. The sidebar role widget — HTML + `roleLabel` / `roleAriaLabel` computeds + the `AuthService` and `CapabilitiesService` injections — is removed. * `portal-admin`'s header passes a hardcoded `'Administrator'`. Every reader who reaches the admin app already carries `Portal.Admin` (guard precondition); no need to re-derive. No i18n marks per ADR-0020's source-locale-only chrome. * The `sidebar.role.{anonymous,administrator,user,aria,label}` translation units are removed from `messages.fr.xlf`. Replaced by `common.role.{administrator,user}` (the new owners are the user menu in either app, hence the generic prefix). The "Anonymous" string is gone too — the chip is hidden on anonymous traffic, the label served no purpose without the user menu rendering it. * The portal-shell sidebar spec drops its three "role label" tests (the widget is gone) and grows a guard test asserting the `data-testid="sidebar-role"` element no longer exists. The setup is simplified back to the pre-#151 shape — no Http testing infrastructure needed, since the sidebar no longer injects `AuthService` / `CapabilitiesService`. * The portal-shell header spec gains two assertions for the role chip inside the open menu panel (Administrator / User). * The shared `UserMenu` spec gains two assertions covering the new input's presence/absence semantics. Verification: * 43 portal-shell specs pass (header +2, sidebar -2 net, balanced). * 50 portal-admin specs pass (no spec edits). * 10 shared-ui specs pass (was 8, +2 for the role chip). * `pnpm nx run portal-shell:build:production` (i18n-strict) green — confirms the xlf cleanup + new `common.role.*` keys are consistent with the source-locale usage. --- .../src/app/components/header/header.html | 1 + .../src/app/components/header/header.ts | 7 + .../src/app/components/header/header.html | 1 + .../src/app/components/header/header.spec.ts | 24 ++++ .../src/app/components/header/header.ts | 14 ++ .../src/app/components/sidebar/sidebar.html | 12 -- .../app/components/sidebar/sidebar.spec.ts | 132 ++++-------------- .../src/app/components/sidebar/sidebar.ts | 24 ---- apps/portal-shell/src/locale/messages.fr.xlf | 16 +-- .../ui/src/lib/user-menu/user-menu.html | 3 + .../ui/src/lib/user-menu/user-menu.scss | 22 +++ .../ui/src/lib/user-menu/user-menu.spec.ts | 28 ++++ libs/shared/ui/src/lib/user-menu/user-menu.ts | 11 ++ 13 files changed, 138 insertions(+), 157 deletions(-) diff --git a/apps/portal-admin/src/app/components/header/header.html b/apps/portal-admin/src/app/components/header/header.html index fd304d4..218cddb 100644 --- a/apps/portal-admin/src/app/components/header/header.html +++ b/apps/portal-admin/src/app/components/header/header.html @@ -32,6 +32,7 @@ [username]="state.user.username" [initials]="initials()" [items]="userMenuItems" + [role]="roleLabel" [signedInAsLabel]="signedInAsLabel" [signOutLabel]="signOutLabel" [triggerAriaLabel]="triggerAriaLabel(state.user.displayName)" diff --git a/apps/portal-admin/src/app/components/header/header.ts b/apps/portal-admin/src/app/components/header/header.ts index bf2824c..bc793ae 100644 --- a/apps/portal-admin/src/app/components/header/header.ts +++ b/apps/portal-admin/src/app/components/header/header.ts @@ -56,6 +56,13 @@ export class AdminHeader { protected readonly signedInAsLabel = 'Signed in as'; protected readonly signOutLabel = 'Sign out'; + // Static role label — every reader who reaches portal-admin holds + // the `Portal.Admin` Entra app role (that's the `AdminRoleGuard` + // precondition for `/api/admin/*`), so the chip is unconditional + // and doesn't need to read `req.session.user.roles`. No i18n marks + // either, per ADR-0020's source-locale-only chrome. + protected readonly roleLabel = 'Administrator'; + protected triggerAriaLabel(displayName: string): string { return `User menu — signed in as ${displayName}`; } diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html index d8bc409..7582263 100644 --- a/apps/portal-shell/src/app/components/header/header.html +++ b/apps/portal-shell/src/app/components/header/header.html @@ -86,6 +86,7 @@ [username]="state.user.username" [initials]="initials()" [items]="userMenuItems()" + [role]="roleLabel()" [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 1d0383b..4e240c8 100644 --- a/apps/portal-shell/src/app/components/header/header.spec.ts +++ b/apps/portal-shell/src/app/components/header/header.spec.ts @@ -176,6 +176,30 @@ describe('Header', () => { expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull(); }); + it('renders the role chip as "Administrator" inside the panel when canAccessAdmin', 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 role = document.body.querySelector('[data-testid="user-menu-role"]'); + expect(role?.textContent?.trim()).toBe('Administrator'); + }); + + it('renders the role chip as "User" inside the panel 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 role = document.body.querySelector('[data-testid="user-menu-role"]'); + expect(role?.textContent?.trim()).toBe('User'); + }); + 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( diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts index 8334df7..59d3c77 100644 --- a/apps/portal-shell/src/app/components/header/header.ts +++ b/apps/portal-shell/src/app/components/header/header.ts @@ -80,6 +80,20 @@ export class Header { protected readonly signedInAsLabel = $localize`:@@header.userMenu.signedInAs:Signed in as`; protected readonly signOutLabel = $localize`:@@header.userMenu.signOut:Sign out`; + /** + * Localised role chip shown in the user-menu panel header — binary + * derivation per ADR-0009's "curated public view": `Administrator` + * when the `/api/me/capabilities` response carries + * `canAccessAdmin: true`, `User` otherwise. The widget is hidden on + * anonymous traffic — the `` only renders for + * authenticated state. + */ + protected readonly roleLabel = computed(() => + this.capabilities.canAccessAdmin() + ? $localize`:@@common.role.administrator:Administrator` + : $localize`:@@common.role.user:User`, + ); + protected triggerAriaLabel(displayName: string): string { return $localize`:@@header.userMenu.trigger.aria:User menu — signed in as ${displayName}:displayName:`; } diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.html b/apps/portal-shell/src/app/components/sidebar/sidebar.html index 5cf10c3..61e241e 100644 --- a/apps/portal-shell/src/app/components/sidebar/sidebar.html +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.html @@ -57,18 +57,6 @@
- @if (!collapsed()) { -

- Role: - {{ roleLabel() }} -

- } -
@if (items().length > 0) { diff --git a/libs/shared/ui/src/lib/user-menu/user-menu.scss b/libs/shared/ui/src/lib/user-menu/user-menu.scss index 8b3025b..7e53180 100644 --- a/libs/shared/ui/src/lib/user-menu/user-menu.scss +++ b/libs/shared/ui/src/lib/user-menu/user-menu.scss @@ -105,6 +105,28 @@ } } +// Compact brand-tinted pill carrying the resolved role label +// (Administrator / User / …). Visually echoes the `.role-chip` +// already used on the admin /profile page, but tightened for the +// menu header density. Aligned `flex-start` via `align-self` so the +// pill doesn't stretch to fill the header column. +.user-menu__header-role { + align-self: flex-start; + margin-top: 0.25rem; + padding: 0.125rem 0.5rem; + border-radius: 999px; + background-color: rgba(29, 78, 216, 0.1); + color: var(--color-brand-primary-700, #1e40af); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.02em; + + :where(.dark) & { + background-color: rgba(96, 165, 250, 0.16); + color: #bfdbfe; + } +} + .user-menu__separator { height: 1px; margin: 0.25rem 0; diff --git a/libs/shared/ui/src/lib/user-menu/user-menu.spec.ts b/libs/shared/ui/src/lib/user-menu/user-menu.spec.ts index 14303bf..7e9d938 100644 --- a/libs/shared/ui/src/lib/user-menu/user-menu.spec.ts +++ b/libs/shared/ui/src/lib/user-menu/user-menu.spec.ts @@ -27,6 +27,7 @@ function renderWith(opts: { username: string; initials: string; items: readonly UserMenuItem[]; + role?: string; signedInAsLabel?: string; signOutLabel?: string; triggerAriaLabel?: string; @@ -36,6 +37,9 @@ function renderWith(opts: { fixture.componentRef.setInput('username', opts.username); fixture.componentRef.setInput('initials', opts.initials); fixture.componentRef.setInput('items', opts.items); + if (opts.role) { + fixture.componentRef.setInput('role', opts.role); + } if (opts.signedInAsLabel) { fixture.componentRef.setInput('signedInAsLabel', opts.signedInAsLabel); } @@ -133,4 +137,28 @@ describe('UserMenu', () => { expect(link).not.toBeNull(); expect(link?.textContent).toContain('Open elsewhere'); }); + + it('renders the role chip in the panel header when `role` is provided', async () => { + const fixture = await setup({ role: 'Administrator' }); + const trigger = (fixture.nativeElement as HTMLElement).querySelector( + 'button[aria-haspopup="menu"]', + ); + trigger?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + const panel = document.body.querySelector('.user-menu__panel'); + const roleChip = panel?.querySelector('[data-testid="user-menu-role"]'); + expect(roleChip?.textContent?.trim()).toBe('Administrator'); + }); + + it('omits the role chip when `role` is undefined', async () => { + const fixture = await setup(); + const trigger = (fixture.nativeElement as HTMLElement).querySelector( + 'button[aria-haspopup="menu"]', + ); + trigger?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(document.body.querySelector('[data-testid="user-menu-role"]')).toBeNull(); + }); }); diff --git a/libs/shared/ui/src/lib/user-menu/user-menu.ts b/libs/shared/ui/src/lib/user-menu/user-menu.ts index 7d0e81e..a79c682 100644 --- a/libs/shared/ui/src/lib/user-menu/user-menu.ts +++ b/libs/shared/ui/src/lib/user-menu/user-menu.ts @@ -65,6 +65,17 @@ export class UserMenu { readonly initials = input.required(); readonly items = input.required(); + /** + * Localised role label displayed as a small chip in the panel + * header, below the username. Optional — when undefined, the role + * line is omitted entirely. The caller decides how to derive the + * label: portal-shell uses the capabilities endpoint + * (`canAccessAdmin` → "Administrator" / "User"); portal-admin + * hardcodes "Administrator" since the `Portal.Admin` role is a + * guard precondition for reaching the admin app at all. + */ + readonly role = input(undefined); + /** Localised label for the "Signed in as {username}" panel header. */ readonly signedInAsLabel = input('Signed in as'); /** Localised label for the trailing Sign out row. */