From 6f26bcdd654081a2e327a96048a45fb7a6e20137 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sat, 16 May 2026 03:09:56 +0200 Subject: [PATCH] feat(shared-ui): move the role label from the portal-shell sidebar into the user menu (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Moves the "Role: …" widget from the bottom of the `portal-shell` sidebar into the user-menu panel header. Result: the role chip appears only when the reader is authenticated (the user menu only renders in that state), removing the noise of "Role: Anonymous" on signed-out traffic and de-duplicating the surface that already carries displayName / username / capability-gated actions. ``` Before: sidebar bottom → "Role: Anonymous" (always rendered, even signed-out) After: user-menu panel → • (only when authenticated) ``` ## What lands ### Shared component — [`UserMenu`](libs/shared/ui/src/lib/user-menu/) gets an optional `role` input When set, a small brand-tinted pill (`data-testid="user-menu-role"`) renders inside the panel header below the username. When undefined, the entire chip is omitted — keeps backwards compat with any future caller that doesn't carry a role concept. ```ts readonly role = input(undefined); ``` Visually echoes the `.role-chip` already used on the admin `/profile` page (rounded brand-tinted pill), tightened for the menu header density and `align-self: flex-start` so the pill doesn't stretch. ### Portal-shell - **Header** ([header.ts](apps/portal-shell/src/app/components/header/header.ts)) gains a `roleLabel` computed that derives `Administrator` / `User` from `CapabilitiesService.canAccessAdmin` — the same logic that previously lived in the sidebar. The template binds it as `[role]="roleLabel()"` on ``. - **Sidebar** ([sidebar.ts](apps/portal-shell/src/app/components/sidebar/sidebar.ts), [sidebar.html](apps/portal-shell/src/app/components/sidebar/sidebar.html)) loses the role widget entirely: HTML block, `roleLabel` + `roleAriaLabel` computeds, and the `AuthService` + `CapabilitiesService` injections (the sidebar no longer needs them). - **Sidebar spec** drops its three "role label" tests, grows a guard test asserting `data-testid="sidebar-role"` no longer exists, and reverts to the pre-#151 setup (no Http testing infrastructure — the sidebar no longer fires `/auth/me` or `/me/capabilities`). - **Header spec** gains two assertions for the role chip inside the open menu panel (`Administrator` when `canAccessAdmin: true`, `User` otherwise). ### Portal-admin - **Header** ([header.ts](apps/portal-admin/src/app/components/header/header.ts)) passes a **hardcoded `'Administrator'`** through `[role]`. Every reader who reaches the admin app already carries `Portal.Admin` (it's the `AdminRoleGuard` precondition for `/api/admin/*`); no need to re-derive. No i18n marks per ADR-0020's source-locale-only chrome. ### i18n Five translation units gone from [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf): ``` sidebar.role.anonymous sidebar.role.administrator sidebar.role.user sidebar.role.aria sidebar.role.label ``` Replaced by two new units under a generic prefix (the new owners are the user menu in either app, not the sidebar): ``` common.role.administrator common.role.user ``` The `Anonymous` string is gone too — the chip is hidden on anonymous traffic, the label served no purpose without the user menu rendering it. ## Notes for the reviewer - **Why a generic `common.role.*` prefix rather than `userMenu.role.*`?** The strings are reusable in any context that needs a curated role display (profile page header in a future PR, an audit log "actor role" column, …). Generic prefix avoids the next rename when a second consumer lands. - **Why hardcode "Administrator" for portal-admin rather than reading `roles`?** The admin SPA's `CurrentUser.roles` carries the raw Entra role string (`Portal.Admin`). Displaying that in the menu header would leak the internal nomenclature into the UI — fine for the `/profile` page (it's explicit context there), wrong for the casual menu glance. The hardcoded label keeps the menu consistent with portal-shell ("Administrator" in both surfaces). If a non-admin role ever reaches portal-admin (the guard rejects it today, but the surface could evolve), this is the one place to revisit. - **No CapabilitiesService dependency on portal-admin.** The admin app doesn't import the service — its session already exposes `roles` on `/api/admin/auth/me`, and the chip is unconditional. Keeps the admin bundle untouched by the user-portal capabilities plumbing. - **A11y check.** The role chip is plain text inside the menu panel; the menu panel itself carries `role="menu"` + `aria-label`. The chip doesn't need an extra label — it reads "Administrator" / "User" in context after the displayName and username, which conveys the meaning. No focus state needed (not interactive). ## Test plan - [x] `pnpm nx run-many -t lint test build --projects=shared-ui,portal-shell,portal-admin` — green. - `shared-ui` — **10 specs pass** (was 8; +2 for the role chip). - `portal-shell` — **43 specs pass** (header +2, sidebar -3 + 1 guard = -2 net, balanced to +0 on the total → 43 unchanged). - `portal-admin` — **50 specs pass** (no spec edits; the `[role]` binding is exercised by the existing user-menu spec via the shared component). - [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build passes; confirms the xlf cleanup + new `common.role.*` keys are consistent with usage. - [ ] **Manual smoke**: - **portal-shell anonymous**: sidebar bottom shows only the collapse toggle (no role line). Click "Sign in" → land back signed in, sidebar bottom unchanged. - **portal-shell signed in (non-admin)**: avatar → open menu → header shows `Signed in as / displayName / username / [User]` chip. - **portal-shell signed in (admin)**: same, chip reads `Administrator`, and the menu carries the `Open Portal Admin` row above Sign out. - **portal-admin signed in**: avatar → open menu → `[Administrator]` chip regardless of which admin user signed in. - Tabbing across the sidebar bottom no longer pauses on a non-interactive `

` element — directly hits the collapse toggle button. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/165 --- .../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. */