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:
@@ -8,6 +8,7 @@ import { AdminModule } from '../admin/admin.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DownstreamModule } from '../downstream/downstream.module';
|
||||
import { MeModule } from '../me/me.module';
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
import { SecurityModule } from '../security/security.module';
|
||||
import { SessionModule } from '../session/session.module';
|
||||
@@ -25,6 +26,7 @@ import { UsersModule } from '../users/users.module';
|
||||
HealthModule,
|
||||
AdminModule,
|
||||
DownstreamModule,
|
||||
MeModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { MeController } from './me.controller';
|
||||
|
||||
function makeReq(
|
||||
user: {
|
||||
oid: string;
|
||||
tid: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
amr: readonly string[];
|
||||
roles: readonly string[];
|
||||
} | null,
|
||||
): Request {
|
||||
return { session: { user } } as unknown as Request;
|
||||
}
|
||||
|
||||
describe('MeController', () => {
|
||||
const controller = new MeController();
|
||||
|
||||
it('returns canAccessAdmin: true when the session carries Portal.Admin', () => {
|
||||
const result = controller.capabilities(
|
||||
makeReq({
|
||||
oid: 'oid-1',
|
||||
tid: 'tenant-1',
|
||||
username: 'admin@apf.example',
|
||||
displayName: 'Admin Smith',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: ['Portal.Admin', 'Other.Role'],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ canAccessAdmin: true });
|
||||
});
|
||||
|
||||
it('returns canAccessAdmin: false when the session lacks Portal.Admin', () => {
|
||||
const result = controller.capabilities(
|
||||
makeReq({
|
||||
oid: 'oid-2',
|
||||
tid: 'tenant-1',
|
||||
username: 'jane.doe@apf.example',
|
||||
displayName: 'Jane Doe',
|
||||
amr: ['pwd'],
|
||||
roles: ['Other.Role'],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ canAccessAdmin: false });
|
||||
});
|
||||
|
||||
it('returns canAccessAdmin: false when the session carries no roles', () => {
|
||||
const result = controller.capabilities(
|
||||
makeReq({
|
||||
oid: 'oid-3',
|
||||
tid: 'tenant-1',
|
||||
username: 'guest@apf.example',
|
||||
displayName: 'Guest',
|
||||
amr: [],
|
||||
roles: [],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ canAccessAdmin: false });
|
||||
});
|
||||
|
||||
it('throws Unauthorized when no session is present', () => {
|
||||
expect(() => controller.capabilities(makeReq(null))).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('does NOT echo the raw roles array in the response (curated view contract)', () => {
|
||||
const result = controller.capabilities(
|
||||
makeReq({
|
||||
oid: 'oid-4',
|
||||
tid: 'tenant-1',
|
||||
username: 'admin@apf.example',
|
||||
displayName: 'Admin',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: ['Portal.Admin', 'Confidential.Role'],
|
||||
}),
|
||||
);
|
||||
expect(Object.keys(result)).toEqual(['canAccessAdmin']);
|
||||
expect(JSON.stringify(result)).not.toContain('Confidential.Role');
|
||||
expect(JSON.stringify(result)).not.toContain('Portal.Admin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Controller, Get, Req, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Curated capabilities derived from the active user-portal session,
|
||||
* keyed by the binary access decisions the SPA needs to drive its
|
||||
* own UI. Deliberately *not* a passthrough of `session.user.roles`:
|
||||
* ADR-0009 §"curated public view" keeps the raw `roles` claim
|
||||
* server-side; the SPA renders gates and cross-app links from the
|
||||
* derived booleans this view exposes.
|
||||
*
|
||||
* Add a key when the SPA needs a new conditional rendering signal.
|
||||
* Never add a key that hands back information not already deducible
|
||||
* from the BFF's own server-side authorization decisions — the
|
||||
* authoritative gate stays on the BFF guards (`@RequireAdmin`,
|
||||
* `@RequireMfa`, …).
|
||||
*/
|
||||
export interface CapabilitiesView {
|
||||
/**
|
||||
* True when the session's roles include the `Portal.Admin` Entra
|
||||
* app role — the SPA uses this to surface the "Open Portal Admin"
|
||||
* entry in the user menu. The actual `/api/admin/*` access is
|
||||
* gated server-side by `AdminRoleGuard`; this flag is UX only.
|
||||
*/
|
||||
readonly canAccessAdmin: boolean;
|
||||
}
|
||||
|
||||
const ADMIN_ROLE = 'Portal.Admin';
|
||||
|
||||
/**
|
||||
* `GET /api/me/capabilities` — projects the active session's roles
|
||||
* onto a curated capabilities view consumed by `portal-shell`. The
|
||||
* SPA calls this once per app boot (and on session refresh) to
|
||||
* decide what to render: today, whether to expose the "Open Portal
|
||||
* Admin" entry in the user menu; future keys land alongside as more
|
||||
* conditional UI is required.
|
||||
*
|
||||
* Per ADR-0009 §"curated public view", the user-portal `/me` does
|
||||
* NOT expose the raw `roles` claim — it stays server-side. This
|
||||
* endpoint is the documented release-valve for binary UX hints,
|
||||
* shaped so consumers can never reconstruct the raw role names.
|
||||
*
|
||||
* Mounted under `/api/me/*` rather than `/api/auth/*`: auth is the
|
||||
* lifecycle (login, callback, /me identity, logout) — capabilities
|
||||
* is a derived session view. Keeping the namespaces distinct gives
|
||||
* the latter room to grow without bloating the auth surface.
|
||||
*
|
||||
* The path-routed session middleware in `main.ts` resolves this
|
||||
* to the user-portal session (`portal_session`), so admin-only
|
||||
* surfaces continue to be reached via `/api/admin/auth/me` for
|
||||
* their identity payload and through the corresponding admin-side
|
||||
* guards for authorization.
|
||||
*/
|
||||
@ApiTags('me')
|
||||
@ApiCookieAuth('portal_session')
|
||||
@Controller('me')
|
||||
export class MeController {
|
||||
@ApiOperation({
|
||||
summary: 'Curated capabilities derived from the active user-portal session',
|
||||
})
|
||||
@Get('capabilities')
|
||||
capabilities(@Req() req: Request): CapabilitiesView {
|
||||
const user = req.session.user;
|
||||
if (!user) {
|
||||
// Same posture as `/api/auth/me`: 401 (rather than 200 with
|
||||
// every-flag-false) so the SPA can distinguish "no session"
|
||||
// from "session exists but no flags". Keeps the consumer code
|
||||
// path identical between the two reads.
|
||||
throw new UnauthorizedException({
|
||||
code: 'unauthenticated',
|
||||
message: 'Unauthenticated',
|
||||
});
|
||||
}
|
||||
return {
|
||||
canAccessAdmin: user.roles.includes(ADMIN_ROLE),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeController } from './me.controller';
|
||||
|
||||
/**
|
||||
* `MeModule` — owns `/api/me/*`, the namespace of session-derived
|
||||
* curated views consumed by `portal-shell`. Currently ships the
|
||||
* capabilities endpoint per ADR-0009 amendment; further projections
|
||||
* (preferences, locale resolution, etc.) land under the same prefix.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [MeController],
|
||||
})
|
||||
export class MeModule {}
|
||||
Reference in New Issue
Block a user