feat(shared-ui): move the role label from the portal-shell sidebar into the user menu
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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user