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
Owner

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 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.

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) 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, 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) 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:

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

  • pnpm nx run-many -t lint test build --projects=shared-ui,portal-shell,portal-admin — green.
    • shared-ui10 specs pass (was 8; +2 for the role chip).
    • portal-shell43 specs pass (header +2, sidebar -3 + 1 guard = -2 net, balanced to +0 on the total → 43 unchanged).
    • portal-admin50 specs pass (no spec edits; the [role] binding is exercised by the existing user-menu spec via the shared component).
  • 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.
## 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.
julien added 1 commit 2026-05-16 03:07:49 +02:00
feat(shared-ui): move the role label from the portal-shell sidebar into the user menu
CI / scan (pull_request) Successful in 3m16s
CI / commits (pull_request) Successful in 3m54s
CI / check (pull_request) Successful in 4m11s
CI / a11y (pull_request) Successful in 2m19s
CI / perf (pull_request) Successful in 5m8s
c49a807966
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.
julien merged commit 6f26bcdd65 into main 2026-05-16 03:09:57 +02:00
julien deleted branch feat/move-role-to-user-menu 2026-05-16 03:09:59 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#165