6f26bcdd65
## 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
88 lines
3.4 KiB
TypeScript
88 lines
3.4 KiB
TypeScript
import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
|
|
import {
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
ViewEncapsulation,
|
|
input,
|
|
output,
|
|
} from '@angular/core';
|
|
import { RouterLink } from '@angular/router';
|
|
import { Icon, type IconName } from '../icon/icon';
|
|
|
|
/**
|
|
* Shape of a single row in the user menu — Profile, Settings,
|
|
* cross-app links, etc. Sign out is NOT modelled here; it is always
|
|
* rendered as the last row in a separate group and emits the
|
|
* dedicated `signOut` output.
|
|
*
|
|
* Use `routerLink` for internal navigation (Angular `RouterLink`),
|
|
* `href` for cross-origin links (other apps, external URLs). At most
|
|
* one of the two should be set.
|
|
*/
|
|
export interface UserMenuItem {
|
|
readonly label: string;
|
|
readonly icon?: IconName;
|
|
readonly routerLink?: string;
|
|
readonly href?: string;
|
|
readonly disabled?: boolean;
|
|
/** Right-aligned chip — e.g. `"Soon"` for not-yet-shipped entries. */
|
|
readonly badge?: string;
|
|
}
|
|
|
|
/**
|
|
* Authenticated user menu — the gitea/github-style avatar dropdown
|
|
* docked at the top right of both `portal-shell` and `portal-admin`
|
|
* headers. Rendered only when the session is `authenticated`; the
|
|
* loading / anonymous / error widgets stay in each app's header
|
|
* because they vary by surface (search bar, sign-up button, etc.).
|
|
*
|
|
* The component is layout-only: caller passes the principal
|
|
* (`displayName`, `username`, `initials`) plus a list of `items`
|
|
* for the navigation rows, and listens for `signOut` to actually
|
|
* destroy the session.
|
|
*
|
|
* Keyboard nav (arrow keys, Home/End, Escape, type-ahead) is
|
|
* provided by `@angular/cdk/menu` — same primitives the
|
|
* `ThemeSwitcher` and `LocaleSwitcher` already use, kept consistent
|
|
* to land familiar interactions across the chrome.
|
|
*/
|
|
@Component({
|
|
selector: 'lib-user-menu',
|
|
imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger, RouterLink, Icon],
|
|
templateUrl: './user-menu.html',
|
|
styleUrl: './user-menu.scss',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
// CDK Menu opens its panel in an overlay portal outside this
|
|
// component's host. Encapsulated styles would not reach the
|
|
// overlay; the BEM-prefixed class names below keep selectors
|
|
// specific enough to avoid leakage. Same pattern as
|
|
// `LocaleSwitcher` / `ThemeSwitcher`.
|
|
encapsulation: ViewEncapsulation.None,
|
|
})
|
|
export class UserMenu {
|
|
readonly displayName = input.required<string>();
|
|
readonly username = input.required<string>();
|
|
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. */
|
|
readonly signOutLabel = input<string>('Sign out');
|
|
/** Localised aria-label for the avatar trigger button. */
|
|
readonly triggerAriaLabel = input<string>('User menu');
|
|
|
|
readonly signOut = output<void>();
|
|
}
|