feat(shared-ui): move the role label from the portal-shell sidebar into the user menu (#165)
## 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 → • <role chip> (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<string | undefined>(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 `<lib-user-menu>`.
- **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 `<p>` element — directly hits the collapse toggle button.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #165
This commit was merged in pull request #165.
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
[username]="state.user.username"
|
||||
[initials]="initials()"
|
||||
[items]="userMenuItems"
|
||||
[role]="roleLabel"
|
||||
[signedInAsLabel]="signedInAsLabel"
|
||||
[signOutLabel]="signOutLabel"
|
||||
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
[username]="state.user.username"
|
||||
[initials]="initials()"
|
||||
[items]="userMenuItems()"
|
||||
[role]="roleLabel()"
|
||||
[signedInAsLabel]="signedInAsLabel"
|
||||
[signOutLabel]="signOutLabel"
|
||||
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
||||
|
||||
@@ -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<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 () => {
|
||||
const { fixture } = await setup({ onBootstrap: 'authenticated', canAccessAdmin: false });
|
||||
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 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 {
|
||||
return $localize`:@@header.userMenu.trigger.aria:User menu — signed in as ${displayName}:displayName:`;
|
||||
}
|
||||
|
||||
@@ -57,18 +57,6 @@
|
||||
</nav>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
(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 { provideRouter } from '@angular/router';
|
||||
import { AUTH_BFF_BASE_URL, type CurrentUser } from 'feature-auth';
|
||||
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', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Sidebar],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
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 ?? '';
|
||||
expect(text).toContain('General');
|
||||
expect(text).toContain('Establishments');
|
||||
@@ -74,14 +21,25 @@ describe('Sidebar', () => {
|
||||
});
|
||||
|
||||
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;
|
||||
expect(root.querySelector('a[href="/accessibility"]')).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 () => {
|
||||
const { fixture } = await setup();
|
||||
const fixture = TestBed.createComponent(Sidebar);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const aside = root.querySelector('aside');
|
||||
expect(aside?.classList.contains('sidebar--collapsed')).toBe(false);
|
||||
@@ -98,50 +56,10 @@ describe('Sidebar', () => {
|
||||
|
||||
it('restores the collapsed state from localStorage on init', async () => {
|
||||
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');
|
||||
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 { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { AuthService } from 'feature-auth';
|
||||
import { Icon, type IconName } from 'shared-ui';
|
||||
import { LayoutStateService } from 'shared-state';
|
||||
import { CapabilitiesService } from '../../services/capabilities.service';
|
||||
|
||||
/**
|
||||
* Primary side navigation.
|
||||
@@ -82,32 +80,10 @@ const MENU: readonly MenuGroup[] = [
|
||||
})
|
||||
export class Sidebar {
|
||||
private readonly layout = inject(LayoutStateService);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly capabilities = inject(CapabilitiesService);
|
||||
|
||||
protected readonly menu = MENU;
|
||||
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(() =>
|
||||
this.collapsed()
|
||||
? $localize`:@@sidebar.toggle.aria.expand:Expand sidebar`
|
||||
|
||||
@@ -146,26 +146,14 @@
|
||||
<source>Messages</source>
|
||||
<target>Messages</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sidebar.role.anonymous" datatype="html">
|
||||
<source>Anonymous</source>
|
||||
<target>Anonyme</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sidebar.role.administrator" datatype="html">
|
||||
<trans-unit id="common.role.administrator" datatype="html">
|
||||
<source>Administrator</source>
|
||||
<target>Administrateur</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sidebar.role.user" datatype="html">
|
||||
<trans-unit id="common.role.user" datatype="html">
|
||||
<source>User</source>
|
||||
<target>Utilisateur</target>
|
||||
</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">
|
||||
<source>Collapse sidebar</source>
|
||||
<target>Replier la barre latérale</target>
|
||||
|
||||
Reference in New Issue
Block a user