feat(shared-ui): move the role label from the portal-shell sidebar into the user menu #165

Merged
julien merged 1 commits from feat/move-role-to-user-menu into main 2026-05-16 03:09:57 +02:00
13 changed files with 138 additions and 157 deletions
@@ -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`
+2 -14
View File
@@ -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. */