feat(shared-ui): move the role label from the portal-shell sidebar into the user menu
CI / scan (pull_request) Successful in 3m16s
CI / commits (pull_request) Successful in 3m54s
CI / check (pull_request) Successful in 4m11s
CI / a11y (pull_request) Successful in 2m19s
CI / perf (pull_request) Successful in 5m8s

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 `<lib-user-menu>` 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.
This commit is contained in:
Julien Gautier
2026-05-16 03:05:22 +02:00
parent 10c80f189d
commit c49a807966
13 changed files with 138 additions and 157 deletions
@@ -14,6 +14,9 @@
<span class="user-menu__header-label">{{ signedInAsLabel() }}</span>
<strong class="user-menu__header-name">{{ displayName() }}</strong>
<span class="user-menu__header-username">{{ username() }}</span>
@if (role(); as roleLabel) {
<span class="user-menu__header-role" data-testid="user-menu-role">{{ roleLabel }}</span>
}
</div>
@if (items().length > 0) {
@@ -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;
@@ -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<HTMLButtonElement>(
'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<HTMLButtonElement>(
'button[aria-haspopup="menu"]',
);
trigger?.click();
fixture.detectChanges();
await fixture.whenStable();
expect(document.body.querySelector('[data-testid="user-menu-role"]')).toBeNull();
});
});
@@ -65,6 +65,17 @@ export class UserMenu {
readonly initials = input.required<string>();
readonly items = input.required<readonly UserMenuItem[]>();
/**
* 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<string | undefined>(undefined);
/** Localised label for the "Signed in as {username}" panel header. */
readonly signedInAsLabel = input<string>('Signed in as');
/** Localised label for the trailing Sign out row. */