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:
@@ -32,6 +32,7 @@
|
|||||||
[username]="state.user.username"
|
[username]="state.user.username"
|
||||||
[initials]="initials()"
|
[initials]="initials()"
|
||||||
[items]="userMenuItems"
|
[items]="userMenuItems"
|
||||||
|
[role]="roleLabel"
|
||||||
[signedInAsLabel]="signedInAsLabel"
|
[signedInAsLabel]="signedInAsLabel"
|
||||||
[signOutLabel]="signOutLabel"
|
[signOutLabel]="signOutLabel"
|
||||||
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ export class AdminHeader {
|
|||||||
protected readonly signedInAsLabel = 'Signed in as';
|
protected readonly signedInAsLabel = 'Signed in as';
|
||||||
protected readonly signOutLabel = 'Sign out';
|
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 {
|
protected triggerAriaLabel(displayName: string): string {
|
||||||
return `User menu — signed in as ${displayName}`;
|
return `User menu — signed in as ${displayName}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
[username]="state.user.username"
|
[username]="state.user.username"
|
||||||
[initials]="initials()"
|
[initials]="initials()"
|
||||||
[items]="userMenuItems()"
|
[items]="userMenuItems()"
|
||||||
|
[role]="roleLabel()"
|
||||||
[signedInAsLabel]="signedInAsLabel"
|
[signedInAsLabel]="signedInAsLabel"
|
||||||
[signOutLabel]="signOutLabel"
|
[signOutLabel]="signOutLabel"
|
||||||
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
||||||
|
|||||||
@@ -176,6 +176,30 @@ describe('Header', () => {
|
|||||||
expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull();
|
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<HTMLButtonElement>(
|
||||||
|
'[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<HTMLButtonElement>(
|
||||||
|
'[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 () => {
|
it('omits the "Open Portal Admin" entry when canAccessAdmin is false', async () => {
|
||||||
const { fixture } = await setup({ onBootstrap: 'authenticated', canAccessAdmin: false });
|
const { fixture } = await setup({ onBootstrap: 'authenticated', canAccessAdmin: false });
|
||||||
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
|
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
|
||||||
|
|||||||
@@ -80,6 +80,20 @@ export class Header {
|
|||||||
protected readonly signedInAsLabel = $localize`:@@header.userMenu.signedInAs:Signed in as`;
|
protected readonly signedInAsLabel = $localize`:@@header.userMenu.signedInAs:Signed in as`;
|
||||||
protected readonly signOutLabel = $localize`:@@header.userMenu.signOut:Sign out`;
|
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 `<lib-user-menu>` 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 {
|
protected triggerAriaLabel(displayName: string): string {
|
||||||
return $localize`:@@header.userMenu.trigger.aria:User menu — signed in as ${displayName}:displayName:`;
|
return $localize`:@@header.userMenu.trigger.aria:User menu — signed in as ${displayName}:displayName:`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,18 +57,6 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 px-2 py-3 dark:border-gray-800">
|
<div class="border-t border-gray-200 px-2 py-3 dark:border-gray-800">
|
||||||
@if (!collapsed()) {
|
|
||||||
<p
|
|
||||||
class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
|
|
||||||
[attr.aria-label]="roleAriaLabel()"
|
|
||||||
>
|
|
||||||
<ng-container i18n="@@sidebar.role.label">Role:</ng-container>
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200" data-testid="sidebar-role"
|
|
||||||
>{{ roleLabel() }}</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="toggle()"
|
(click)="toggle()"
|
||||||
|
|||||||
@@ -1,72 +1,19 @@
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { AUTH_BFF_BASE_URL, type CurrentUser } from 'feature-auth';
|
|
||||||
import { Sidebar } from './sidebar';
|
import { Sidebar } from './sidebar';
|
||||||
|
|
||||||
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<typeof TestBed.createComponent>) {
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
await Promise.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Sidebar', () => {
|
describe('Sidebar', () => {
|
||||||
beforeEach(() => localStorage.clear());
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Sidebar],
|
||||||
|
providers: [provideRouter([])],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the three v1 menu groups', async () => {
|
it('renders the three v1 menu groups', async () => {
|
||||||
const { fixture } = await setup();
|
const fixture = TestBed.createComponent(Sidebar);
|
||||||
|
await fixture.whenStable();
|
||||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||||
expect(text).toContain('General');
|
expect(text).toContain('General');
|
||||||
expect(text).toContain('Establishments');
|
expect(text).toContain('Establishments');
|
||||||
@@ -74,14 +21,25 @@ describe('Sidebar', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not render the accessibility links (now lives in the footer)', async () => {
|
it('does not render the accessibility links (now lives in the footer)', async () => {
|
||||||
const { fixture } = await setup();
|
const fixture = TestBed.createComponent(Sidebar);
|
||||||
|
await fixture.whenStable();
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('a[href="/accessibility"]')).toBeNull();
|
expect(root.querySelector('a[href="/accessibility"]')).toBeNull();
|
||||||
expect(root.querySelector('a[href="/accessibilite"]')).toBeNull();
|
expect(root.querySelector('a[href="/accessibilite"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('no longer renders the role widget (moved into the user menu)', async () => {
|
||||||
|
const fixture = TestBed.createComponent(Sidebar);
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(
|
||||||
|
(fixture.nativeElement as HTMLElement).querySelector('[data-testid="sidebar-role"]'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('starts expanded and collapses on toggle, persisting to localStorage', async () => {
|
it('starts expanded and collapses on toggle, persisting to localStorage', async () => {
|
||||||
const { fixture } = await setup();
|
const fixture = TestBed.createComponent(Sidebar);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
const aside = root.querySelector('aside');
|
const aside = root.querySelector('aside');
|
||||||
expect(aside?.classList.contains('sidebar--collapsed')).toBe(false);
|
expect(aside?.classList.contains('sidebar--collapsed')).toBe(false);
|
||||||
@@ -98,50 +56,10 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
it('restores the collapsed state from localStorage on init', async () => {
|
it('restores the collapsed state from localStorage on init', async () => {
|
||||||
localStorage.setItem('portal-shell:sidebar-collapsed', '1');
|
localStorage.setItem('portal-shell:sidebar-collapsed', '1');
|
||||||
const { fixture } = await setup();
|
const fixture = TestBed.createComponent(Sidebar);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
const aside = (fixture.nativeElement as HTMLElement).querySelector('aside');
|
const aside = (fixture.nativeElement as HTMLElement).querySelector('aside');
|
||||||
expect(aside?.classList.contains('sidebar--collapsed')).toBe(true);
|
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
import { AuthService } from 'feature-auth';
|
|
||||||
import { Icon, type IconName } from 'shared-ui';
|
import { Icon, type IconName } from 'shared-ui';
|
||||||
import { LayoutStateService } from 'shared-state';
|
import { LayoutStateService } from 'shared-state';
|
||||||
import { CapabilitiesService } from '../../services/capabilities.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Primary side navigation.
|
* Primary side navigation.
|
||||||
@@ -82,32 +80,10 @@ const MENU: readonly MenuGroup[] = [
|
|||||||
})
|
})
|
||||||
export class Sidebar {
|
export class Sidebar {
|
||||||
private readonly layout = inject(LayoutStateService);
|
private readonly layout = inject(LayoutStateService);
|
||||||
private readonly auth = inject(AuthService);
|
|
||||||
private readonly capabilities = inject(CapabilitiesService);
|
|
||||||
|
|
||||||
protected readonly menu = MENU;
|
protected readonly menu = MENU;
|
||||||
protected readonly collapsed = this.layout.sidebarCollapsed;
|
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(() =>
|
protected readonly toggleAriaLabel = computed(() =>
|
||||||
this.collapsed()
|
this.collapsed()
|
||||||
? $localize`:@@sidebar.toggle.aria.expand:Expand sidebar`
|
? $localize`:@@sidebar.toggle.aria.expand:Expand sidebar`
|
||||||
|
|||||||
@@ -146,26 +146,14 @@
|
|||||||
<source>Messages</source>
|
<source>Messages</source>
|
||||||
<target>Messages</target>
|
<target>Messages</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="sidebar.role.anonymous" datatype="html">
|
<trans-unit id="common.role.administrator" datatype="html">
|
||||||
<source>Anonymous</source>
|
|
||||||
<target>Anonyme</target>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="sidebar.role.administrator" datatype="html">
|
|
||||||
<source>Administrator</source>
|
<source>Administrator</source>
|
||||||
<target>Administrateur</target>
|
<target>Administrateur</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="sidebar.role.user" datatype="html">
|
<trans-unit id="common.role.user" datatype="html">
|
||||||
<source>User</source>
|
<source>User</source>
|
||||||
<target>Utilisateur</target>
|
<target>Utilisateur</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="sidebar.role.aria" datatype="html">
|
|
||||||
<source>Current role: <x id="role" equiv-text="role"/></source>
|
|
||||||
<target>Rôle actuel : <x id="role" equiv-text="role"/></target>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="sidebar.role.label" datatype="html">
|
|
||||||
<source>Role:</source>
|
|
||||||
<target>Rôle :</target>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="sidebar.toggle.aria.collapse" datatype="html">
|
<trans-unit id="sidebar.toggle.aria.collapse" datatype="html">
|
||||||
<source>Collapse sidebar</source>
|
<source>Collapse sidebar</source>
|
||||||
<target>Replier la barre latérale</target>
|
<target>Replier la barre latérale</target>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
<span class="user-menu__header-label">{{ signedInAsLabel() }}</span>
|
<span class="user-menu__header-label">{{ signedInAsLabel() }}</span>
|
||||||
<strong class="user-menu__header-name">{{ displayName() }}</strong>
|
<strong class="user-menu__header-name">{{ displayName() }}</strong>
|
||||||
<span class="user-menu__header-username">{{ username() }}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
@if (items().length > 0) {
|
@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 {
|
.user-menu__separator {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function renderWith(opts: {
|
|||||||
username: string;
|
username: string;
|
||||||
initials: string;
|
initials: string;
|
||||||
items: readonly UserMenuItem[];
|
items: readonly UserMenuItem[];
|
||||||
|
role?: string;
|
||||||
signedInAsLabel?: string;
|
signedInAsLabel?: string;
|
||||||
signOutLabel?: string;
|
signOutLabel?: string;
|
||||||
triggerAriaLabel?: string;
|
triggerAriaLabel?: string;
|
||||||
@@ -36,6 +37,9 @@ function renderWith(opts: {
|
|||||||
fixture.componentRef.setInput('username', opts.username);
|
fixture.componentRef.setInput('username', opts.username);
|
||||||
fixture.componentRef.setInput('initials', opts.initials);
|
fixture.componentRef.setInput('initials', opts.initials);
|
||||||
fixture.componentRef.setInput('items', opts.items);
|
fixture.componentRef.setInput('items', opts.items);
|
||||||
|
if (opts.role) {
|
||||||
|
fixture.componentRef.setInput('role', opts.role);
|
||||||
|
}
|
||||||
if (opts.signedInAsLabel) {
|
if (opts.signedInAsLabel) {
|
||||||
fixture.componentRef.setInput('signedInAsLabel', opts.signedInAsLabel);
|
fixture.componentRef.setInput('signedInAsLabel', opts.signedInAsLabel);
|
||||||
}
|
}
|
||||||
@@ -133,4 +137,28 @@ describe('UserMenu', () => {
|
|||||||
expect(link).not.toBeNull();
|
expect(link).not.toBeNull();
|
||||||
expect(link?.textContent).toContain('Open elsewhere');
|
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 initials = input.required<string>();
|
||||||
readonly items = input.required<readonly UserMenuItem[]>();
|
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. */
|
/** Localised label for the "Signed in as {username}" panel header. */
|
||||||
readonly signedInAsLabel = input<string>('Signed in as');
|
readonly signedInAsLabel = input<string>('Signed in as');
|
||||||
/** Localised label for the trailing Sign out row. */
|
/** Localised label for the trailing Sign out row. */
|
||||||
|
|||||||
Reference in New Issue
Block a user