feat(portal): /api/me/capabilities + cross-app menu links + real role label (#151)
## Summary PR 3 of 3 — final piece of the user-menu / profile / cross-app chantier. Closes the loop with the BFF capabilities endpoint, symmetric cross-app entries in both user menus, and a real role label in place of the hardcoded "Anonymous" widget on the portal-shell sidebar. | PR | Périmètre | | --- | --- | | PR 1 ✅ | Shared `UserMenu` dropdown + integration. | | PR 2 ✅ | `/profile` pages on both apps. | | **PR 3 (this one)** | `GET /api/me/capabilities` + real sidebar role label + cross-app menu entries. | ## What lands ### BFF — `GET /api/me/capabilities` New [`MeModule`](apps/portal-bff/src/me/me.module.ts) wiring a single endpoint: ```ts GET /api/me/capabilities → { canAccessAdmin: boolean } ``` Resolved against the user-portal session ([`portal_session`](apps/portal-bff/src/me/me.controller.ts) — the path-routed session middleware in `main.ts` already maps `/api/me/*` to that session). Returns 401 if no session is present, consistent with `/api/auth/me`. 5 specs cover the four state combinations + a regression-fence asserting the curated view never leaks the raw `roles` array. ### ADR-0009 amendment [`docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md`](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) — new **"Curated public view"** section codifies the design stance the user picked when we agreed the staging: > The `/auth/me` payload exposes a deliberately narrow projection of the session: `oid`, `tid`, `username`, `displayName`. **The raw `roles` claim is _not_ part of `/auth/me`** — it stays server-side […]. The SPA derives binary UX hints from a dedicated companion endpoint […]. The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles […] does not widen the SPA-side surface. The routes table grows a row for `/me/capabilities`. ### Portal-shell — capabilities-driven UI - **New service** [`CapabilitiesService`](apps/portal-shell/src/app/services/capabilities.service.ts) (app-local, not in `feature-auth` — the admin app has its own roles channel via `/api/admin/auth/me` and would never use this). Signals: `capabilities`, `canAccessAdmin`. Fires `GET /me/capabilities` reactively via an `effect` that watches `auth.currentUser()`. Anonymous sessions short-circuit to the all-false default without a fetch — the BFF would 401 anyway. - **Header** ([`header.ts`](apps/portal-shell/src/app/components/header/header.ts)) — `userMenuItems` is now a `computed`. Profile + Settings stay unconditional; **"Open Portal Admin"** appears only when `canAccessAdmin()` flips true, with `href = environment.adminAppUrl`. Per ADR-0020 the two SPAs live on distinct origins, so this is a raw cross-origin anchor, not a routerLink. - **Sidebar** ([`sidebar.ts`](apps/portal-shell/src/app/components/sidebar/sidebar.ts) + [`sidebar.html`](apps/portal-shell/src/app/components/sidebar/sidebar.html)) — the hardcoded `Role: Anonymous` widget is replaced by a derived computed: - `Anonymous` when no session. - `Administrator` when `canAccessAdmin()` is true. - `User` otherwise (signed-in, no admin). The aria-label gains a `role` placeholder so screen readers hear the live value. ### Portal-admin — symmetric cross-app entry - **Header** ([`header.ts`](apps/portal-admin/src/app/components/header/header.ts)) — adds an unconditional `Open Portal Shell` row pointing at `environment.shellAppUrl`. Anyone able to reach portal-admin can reach portal-shell, so no capabilities check needed; admins always benefit from a one-click jump back to the end-user surface. ### Environments `adminAppUrl` and `shellAppUrl` added to the respective [`environment.ts`](apps/portal-shell/src/environments/environment.ts) files (dev defaults: `:4300` for admin, `:4200` for shell). Per-env siblings (staging / prod) will override the host once they exist, per ADR-0018. ### i18n | Key | EN source | FR target | | --- | --- | --- | | `header.userMenu.openAdmin` | Open Portal Admin | Ouvrir Administration APF Portal | | `sidebar.role.administrator` | Administrator | Administrateur | | `sidebar.role.user` | User | Utilisateur | | `sidebar.role.aria` | reshaped with `{role}` placeholder | reshaped likewise | Admin-side strings stay in English source per ADR-0020. ## Notes for the reviewer - **Why `CapabilitiesService` in the app, not in `feature-auth`?** Only `portal-shell` will ever call `/api/me/capabilities` — the admin SPA hits `/api/admin/auth/me` which already returns `roles`. Putting the service in `feature-auth` would publish a tree-shakable `providedIn: 'root'` injectable that ships in both bundles. Keeping it app-local makes the boundary explicit. - **Why a `computed` for `userMenuItems` rather than mutating an array in an `effect`?** Signals + computed = single source of truth. The shared `UserMenu` re-renders automatically when the items list changes (whenever capabilities flips). Less ceremony than maintaining a `WritableSignal<UserMenuItem[]>`. - **Why the `flushPendingEffects` test helper?** Zoneless apps rely on the signals scheduler to dispatch `effect()` callbacks via micro-task scheduling. `fixture.detectChanges() + whenStable()` once is not enough: the chain is `meReq.flush()` → `_state.set()` → effect scheduled → effect fires → `http.get()` queued. The helper loops 4× to give the scheduler enough rounds to settle before `expectOne(CAPABILITIES_URL)` looks up the request. - **Why no test for the dev URL values?** `environment.ts` is config that gets swapped at build time per ADR-0018; the values themselves are environmental. Asserting the dev value in a test would lock in a port (4200/4300) that's separately configured in `project.json`. ## Test plan - [x] `pnpm nx test portal-bff` — **401 specs pass** (was 396, +5 for `MeController`). - [x] `pnpm nx test portal-shell` — **40 specs pass** (was 35, +5: 3 sidebar role-label + 2 header admin-link). - [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — 18/18 tasks green, including the i18n-strict `portal-shell:build:production`. - [ ] Manual smoke (with a `Portal.Admin`-assigned account): - Sign in on portal-shell → sidebar reads `Role: Administrator`, user menu lists `Open Portal Admin` at the bottom (right above the Sign-out separator), clicking the link opens `localhost:4300`. - Sign in on portal-admin → user menu lists `Open Portal Shell`, clicking opens `localhost:4200`. - Sign in on portal-shell with a non-admin account → sidebar reads `Role: User`, `Open Portal Admin` is absent. - Sign out → sidebar reads `Role: Anonymous`, the menu collapses to its anonymous-state Sign-in button. ## What's next Chantier closed. The user-menu shape is now stable; further entries (notifications inbox, theme override, locale switcher inside the menu rather than the footer) plug into the existing `items` API without re-shaping the component. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #151
This commit was merged in pull request #151.
This commit is contained in:
@@ -95,7 +95,7 @@
|
||||
[displayName]="state.user.displayName"
|
||||
[username]="state.user.username"
|
||||
[initials]="initials()"
|
||||
[items]="userMenuItems"
|
||||
[items]="userMenuItems()"
|
||||
[signedInAsLabel]="signedInAsLabel"
|
||||
[signOutLabel]="signOutLabel"
|
||||
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Header } from './header';
|
||||
|
||||
const BFF_BASE = 'http://bff.test/api';
|
||||
const ME_URL = `${BFF_BASE}/auth/me`;
|
||||
const CAPABILITIES_URL = `${BFF_BASE}/me/capabilities`;
|
||||
|
||||
const USER: CurrentUser = {
|
||||
oid: 'user-oid',
|
||||
@@ -15,7 +16,10 @@ const USER: CurrentUser = {
|
||||
displayName: 'Jane Doe',
|
||||
};
|
||||
|
||||
async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'error' | 'leave' }) {
|
||||
async function setup(opts?: {
|
||||
onBootstrap?: 'authenticated' | 'anonymous' | 'error' | 'leave';
|
||||
canAccessAdmin?: boolean;
|
||||
}) {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Header],
|
||||
providers: [
|
||||
@@ -35,6 +39,14 @@ async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'err
|
||||
const req = httpCtrl.expectOne(ME_URL);
|
||||
if (intent === 'authenticated') {
|
||||
req.flush(USER);
|
||||
// Multiple change-detection rounds + microtasks let Angular's
|
||||
// signal scheduler dispatch the `effect()` in
|
||||
// `CapabilitiesService` so its GET request is queued before
|
||||
// we look it up below.
|
||||
await flushPendingEffects(fixture);
|
||||
httpCtrl.expectOne(CAPABILITIES_URL).flush({
|
||||
canAccessAdmin: opts?.canAccessAdmin ?? false,
|
||||
});
|
||||
} else if (intent === 'anonymous') {
|
||||
req.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' });
|
||||
} else {
|
||||
@@ -42,11 +54,18 @@ async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'err
|
||||
}
|
||||
await Promise.resolve();
|
||||
}
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await flushPendingEffects(fixture);
|
||||
return { fixture, httpCtrl };
|
||||
}
|
||||
|
||||
async function flushPendingEffects(fixture: ReturnType<typeof TestBed.createComponent>) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -151,6 +170,35 @@ describe('Header', () => {
|
||||
expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull();
|
||||
});
|
||||
|
||||
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>(
|
||||
'[data-testid="user-menu"] button[aria-haspopup="menu"]',
|
||||
);
|
||||
trigger?.click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const items = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('.user-menu__panel .user-menu__item'),
|
||||
);
|
||||
expect(items.some((el) => el.textContent?.includes('Open Portal Admin'))).toBe(false);
|
||||
});
|
||||
|
||||
it('includes the "Open Portal Admin" entry pointing at the admin app when canAccessAdmin is true', 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 adminLink = document.body.querySelector<HTMLAnchorElement>(
|
||||
'.user-menu__panel a.user-menu__item[href^="http"]',
|
||||
);
|
||||
expect(adminLink).not.toBeNull();
|
||||
expect(adminLink?.textContent).toContain('Open Portal Admin');
|
||||
});
|
||||
|
||||
it('shows the error chip when /me fails with a non-401', async () => {
|
||||
const { fixture } = await setup({ onBootstrap: 'error' });
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { RouterLink } from '@angular/router';
|
||||
import { AuthService, type CurrentUser } from 'feature-auth';
|
||||
import { Icon, UserMenu, type UserMenuItem } from 'shared-ui';
|
||||
import { LayoutStateService } from 'shared-state';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { CapabilitiesService } from '../../services/capabilities.service';
|
||||
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
||||
|
||||
/**
|
||||
@@ -35,6 +37,7 @@ import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
||||
export class Header {
|
||||
private readonly layout = inject(LayoutStateService);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly capabilities = inject(CapabilitiesService);
|
||||
|
||||
protected readonly collapsed = this.layout.sidebarCollapsed;
|
||||
|
||||
@@ -45,22 +48,34 @@ export class Header {
|
||||
return user ? initialsFor(user) : '';
|
||||
});
|
||||
|
||||
// Static menu rows for the authenticated user. Sign out is NOT in
|
||||
// this list — it's the dedicated row at the bottom of the panel,
|
||||
// emitted via the menu's `signOut` output.
|
||||
protected readonly userMenuItems: readonly UserMenuItem[] = [
|
||||
{
|
||||
label: $localize`:@@header.userMenu.profile:Profile`,
|
||||
icon: 'user',
|
||||
routerLink: '/profile',
|
||||
},
|
||||
{
|
||||
label: $localize`:@@header.userMenu.settings:Settings`,
|
||||
icon: 'settings',
|
||||
disabled: true,
|
||||
badge: $localize`:@@common.badge.soon:Soon`,
|
||||
},
|
||||
];
|
||||
// Menu rows are recomputed when capabilities flip so the "Open
|
||||
// Portal Admin" entry appears as soon as the BFF confirms admin
|
||||
// access for the current session. Sign out is NOT in this list —
|
||||
// it's the dedicated row at the bottom of the panel, emitted via
|
||||
// the menu's `signOut` output.
|
||||
protected readonly userMenuItems = computed<readonly UserMenuItem[]>(() => {
|
||||
const items: UserMenuItem[] = [
|
||||
{
|
||||
label: $localize`:@@header.userMenu.profile:Profile`,
|
||||
icon: 'user',
|
||||
routerLink: '/profile',
|
||||
},
|
||||
{
|
||||
label: $localize`:@@header.userMenu.settings:Settings`,
|
||||
icon: 'settings',
|
||||
disabled: true,
|
||||
badge: $localize`:@@common.badge.soon:Soon`,
|
||||
},
|
||||
];
|
||||
if (this.capabilities.canAccessAdmin()) {
|
||||
items.push({
|
||||
label: $localize`:@@header.userMenu.openAdmin:Open Portal Admin`,
|
||||
icon: 'wrench',
|
||||
href: environment.adminAppUrl,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
protected readonly signedInAsLabel = $localize`:@@header.userMenu.signedInAs:Signed in as`;
|
||||
protected readonly signOutLabel = $localize`:@@header.userMenu.signOut:Sign out`;
|
||||
|
||||
Reference in New Issue
Block a user