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:
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/c
|
|||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { AuthService, type CurrentUser } from 'feature-auth';
|
import { AuthService, type CurrentUser } from 'feature-auth';
|
||||||
import { UserMenu, type UserMenuItem } from 'shared-ui';
|
import { UserMenu, type UserMenuItem } from 'shared-ui';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin-portal header. Deliberately leaner than the user-portal
|
* Admin-portal header. Deliberately leaner than the user-portal
|
||||||
@@ -43,12 +44,13 @@ export class AdminHeader {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Static menu rows. Same shape as the portal-shell menu so a user
|
// Static menu rows. Same shape as the portal-shell menu so a user
|
||||||
// who learned one finds the other familiar; the cross-app
|
// who learned one finds the other familiar. The "Open Portal
|
||||||
// "Open Portal Shell" entry lands with PR 3 once the role +
|
// Shell" entry is unconditional here — the user-portal is public,
|
||||||
// environment plumbing is in place.
|
// any admin can jump back to it.
|
||||||
protected readonly userMenuItems: readonly UserMenuItem[] = [
|
protected readonly userMenuItems: readonly UserMenuItem[] = [
|
||||||
{ label: 'Profile', icon: 'user', routerLink: '/profile' },
|
{ label: 'Profile', icon: 'user', routerLink: '/profile' },
|
||||||
{ label: 'Settings', icon: 'settings', disabled: true, badge: 'Soon' },
|
{ label: 'Settings', icon: 'settings', disabled: true, badge: 'Soon' },
|
||||||
|
{ label: 'Open Portal Shell', icon: 'home', href: environment.shellAppUrl },
|
||||||
];
|
];
|
||||||
|
|
||||||
protected readonly signedInAsLabel = 'Signed in as';
|
protected readonly signedInAsLabel = 'Signed in as';
|
||||||
|
|||||||
@@ -34,4 +34,13 @@ export const environment = {
|
|||||||
* OTLP/HTTP traces endpoint — same OTel collector as portal-shell.
|
* OTLP/HTTP traces endpoint — same OTel collector as portal-shell.
|
||||||
*/
|
*/
|
||||||
otlpEndpoint: 'http://localhost:4318/v1/traces',
|
otlpEndpoint: 'http://localhost:4318/v1/traces',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origin of the sibling `portal-shell` SPA — drives the "Open
|
||||||
|
* Portal Shell" entry in the admin user menu. Symmetric with
|
||||||
|
* `portal-shell`'s `adminAppUrl`; per ADR-0020 the two SPAs live
|
||||||
|
* on distinct origins, so this is a raw cross-origin `<a href>`,
|
||||||
|
* not an Angular route.
|
||||||
|
*/
|
||||||
|
shellAppUrl: 'http://localhost:4200',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AdminModule } from '../admin/admin.module';
|
|||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { DownstreamModule } from '../downstream/downstream.module';
|
import { DownstreamModule } from '../downstream/downstream.module';
|
||||||
|
import { MeModule } from '../me/me.module';
|
||||||
import { RedisModule } from '../redis/redis.module';
|
import { RedisModule } from '../redis/redis.module';
|
||||||
import { SecurityModule } from '../security/security.module';
|
import { SecurityModule } from '../security/security.module';
|
||||||
import { SessionModule } from '../session/session.module';
|
import { SessionModule } from '../session/session.module';
|
||||||
@@ -25,6 +26,7 @@ import { UsersModule } from '../users/users.module';
|
|||||||
HealthModule,
|
HealthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
DownstreamModule,
|
DownstreamModule,
|
||||||
|
MeModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
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 {}
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
[displayName]="state.user.displayName"
|
[displayName]="state.user.displayName"
|
||||||
[username]="state.user.username"
|
[username]="state.user.username"
|
||||||
[initials]="initials()"
|
[initials]="initials()"
|
||||||
[items]="userMenuItems"
|
[items]="userMenuItems()"
|
||||||
[signedInAsLabel]="signedInAsLabel"
|
[signedInAsLabel]="signedInAsLabel"
|
||||||
[signOutLabel]="signOutLabel"
|
[signOutLabel]="signOutLabel"
|
||||||
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Header } from './header';
|
|||||||
|
|
||||||
const BFF_BASE = 'http://bff.test/api';
|
const BFF_BASE = 'http://bff.test/api';
|
||||||
const ME_URL = `${BFF_BASE}/auth/me`;
|
const ME_URL = `${BFF_BASE}/auth/me`;
|
||||||
|
const CAPABILITIES_URL = `${BFF_BASE}/me/capabilities`;
|
||||||
|
|
||||||
const USER: CurrentUser = {
|
const USER: CurrentUser = {
|
||||||
oid: 'user-oid',
|
oid: 'user-oid',
|
||||||
@@ -15,7 +16,10 @@ const USER: CurrentUser = {
|
|||||||
displayName: 'Jane Doe',
|
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({
|
TestBed.configureTestingModule({
|
||||||
imports: [Header],
|
imports: [Header],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -35,6 +39,14 @@ async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'err
|
|||||||
const req = httpCtrl.expectOne(ME_URL);
|
const req = httpCtrl.expectOne(ME_URL);
|
||||||
if (intent === 'authenticated') {
|
if (intent === 'authenticated') {
|
||||||
req.flush(USER);
|
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') {
|
} else if (intent === 'anonymous') {
|
||||||
req.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' });
|
req.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' });
|
||||||
} else {
|
} else {
|
||||||
@@ -42,9 +54,16 @@ async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'err
|
|||||||
}
|
}
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
await flushPendingEffects(fixture);
|
||||||
|
return { fixture, httpCtrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPendingEffects(fixture: ReturnType<typeof TestBed.createComponent>) {
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
return { fixture, httpCtrl };
|
await Promise.resolve();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Header', () => {
|
describe('Header', () => {
|
||||||
@@ -151,6 +170,35 @@ describe('Header', () => {
|
|||||||
expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull();
|
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 () => {
|
it('shows the error chip when /me fails with a non-401', async () => {
|
||||||
const { fixture } = await setup({ onBootstrap: 'error' });
|
const { fixture } = await setup({ onBootstrap: 'error' });
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { RouterLink } from '@angular/router';
|
|||||||
import { AuthService, type CurrentUser } from 'feature-auth';
|
import { AuthService, type CurrentUser } from 'feature-auth';
|
||||||
import { Icon, UserMenu, type UserMenuItem } from 'shared-ui';
|
import { Icon, UserMenu, type UserMenuItem } from 'shared-ui';
|
||||||
import { LayoutStateService } from 'shared-state';
|
import { LayoutStateService } from 'shared-state';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { CapabilitiesService } from '../../services/capabilities.service';
|
||||||
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +37,7 @@ import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
|||||||
export class Header {
|
export class Header {
|
||||||
private readonly layout = inject(LayoutStateService);
|
private readonly layout = inject(LayoutStateService);
|
||||||
private readonly auth = inject(AuthService);
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly capabilities = inject(CapabilitiesService);
|
||||||
|
|
||||||
protected readonly collapsed = this.layout.sidebarCollapsed;
|
protected readonly collapsed = this.layout.sidebarCollapsed;
|
||||||
|
|
||||||
@@ -45,10 +48,13 @@ export class Header {
|
|||||||
return user ? initialsFor(user) : '';
|
return user ? initialsFor(user) : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Static menu rows for the authenticated user. Sign out is NOT in
|
// Menu rows are recomputed when capabilities flip so the "Open
|
||||||
// this list — it's the dedicated row at the bottom of the panel,
|
// Portal Admin" entry appears as soon as the BFF confirms admin
|
||||||
// emitted via the menu's `signOut` output.
|
// access for the current session. Sign out is NOT in this list —
|
||||||
protected readonly userMenuItems: readonly UserMenuItem[] = [
|
// 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`,
|
label: $localize`:@@header.userMenu.profile:Profile`,
|
||||||
icon: 'user',
|
icon: 'user',
|
||||||
@@ -61,6 +67,15 @@ export class Header {
|
|||||||
badge: $localize`:@@common.badge.soon:Soon`,
|
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 signedInAsLabel = $localize`:@@header.userMenu.signedInAs:Signed in as`;
|
||||||
protected readonly signOutLabel = $localize`:@@header.userMenu.signOut:Sign out`;
|
protected readonly signOutLabel = $localize`:@@header.userMenu.signOut:Sign out`;
|
||||||
|
|||||||
@@ -60,12 +60,11 @@
|
|||||||
@if (!collapsed()) {
|
@if (!collapsed()) {
|
||||||
<p
|
<p
|
||||||
class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
|
class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
|
||||||
i18n-aria-label="@@sidebar.role.aria"
|
[attr.aria-label]="roleAriaLabel()"
|
||||||
aria-label="Current role: anonymous (signed-out)"
|
|
||||||
>
|
>
|
||||||
<ng-container i18n="@@sidebar.role.label">Role:</ng-container>
|
<ng-container i18n="@@sidebar.role.label">Role:</ng-container>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200" i18n="@@sidebar.role.anonymous"
|
<span class="font-medium text-gray-700 dark:text-gray-200" data-testid="sidebar-role"
|
||||||
>Anonymous</span
|
>{{ roleLabel() }}</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,72 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { AUTH_BFF_BASE_URL, type CurrentUser } from 'feature-auth';
|
||||||
import { Sidebar } from './sidebar';
|
import { Sidebar } from './sidebar';
|
||||||
|
|
||||||
describe('Sidebar', () => {
|
const BFF_BASE = 'http://bff.test/api';
|
||||||
beforeEach(async () => {
|
const ME_URL = `${BFF_BASE}/auth/me`;
|
||||||
localStorage.clear();
|
const CAPABILITIES_URL = `${BFF_BASE}/me/capabilities`;
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
|
interface SetupOpts {
|
||||||
|
user?: CurrentUser | null;
|
||||||
|
canAccessAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(opts: SetupOpts = {}) {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
imports: [Sidebar],
|
imports: [Sidebar],
|
||||||
providers: [provideRouter([])],
|
providers: [
|
||||||
}).compileComponents();
|
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());
|
||||||
|
|
||||||
it('renders the three v1 menu groups', async () => {
|
it('renders the three v1 menu groups', async () => {
|
||||||
const fixture = TestBed.createComponent(Sidebar);
|
const { fixture } = await setup();
|
||||||
await fixture.whenStable();
|
|
||||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||||
expect(text).toContain('General');
|
expect(text).toContain('General');
|
||||||
expect(text).toContain('Establishments');
|
expect(text).toContain('Establishments');
|
||||||
@@ -21,17 +74,14 @@ describe('Sidebar', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not render the accessibility links (now lives in the footer)', async () => {
|
it('does not render the accessibility links (now lives in the footer)', async () => {
|
||||||
const fixture = TestBed.createComponent(Sidebar);
|
const { fixture } = await setup();
|
||||||
await fixture.whenStable();
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('a[href="/accessibility"]')).toBeNull();
|
expect(root.querySelector('a[href="/accessibility"]')).toBeNull();
|
||||||
expect(root.querySelector('a[href="/accessibilite"]')).toBeNull();
|
expect(root.querySelector('a[href="/accessibilite"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts expanded and collapses on toggle, persisting to localStorage', async () => {
|
it('starts expanded and collapses on toggle, persisting to localStorage', async () => {
|
||||||
const fixture = TestBed.createComponent(Sidebar);
|
const { fixture } = await setup();
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
const aside = root.querySelector('aside');
|
const aside = root.querySelector('aside');
|
||||||
expect(aside?.classList.contains('sidebar--collapsed')).toBe(false);
|
expect(aside?.classList.contains('sidebar--collapsed')).toBe(false);
|
||||||
@@ -48,10 +98,50 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
it('restores the collapsed state from localStorage on init', async () => {
|
it('restores the collapsed state from localStorage on init', async () => {
|
||||||
localStorage.setItem('portal-shell:sidebar-collapsed', '1');
|
localStorage.setItem('portal-shell:sidebar-collapsed', '1');
|
||||||
const fixture = TestBed.createComponent(Sidebar);
|
const { fixture } = await setup();
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
const aside = (fixture.nativeElement as HTMLElement).querySelector('aside');
|
const aside = (fixture.nativeElement as HTMLElement).querySelector('aside');
|
||||||
expect(aside?.classList.contains('sidebar--collapsed')).toBe(true);
|
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,7 +1,9 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { AuthService } from 'feature-auth';
|
||||||
import { Icon, type IconName } from 'shared-ui';
|
import { Icon, type IconName } from 'shared-ui';
|
||||||
import { LayoutStateService } from 'shared-state';
|
import { LayoutStateService } from 'shared-state';
|
||||||
|
import { CapabilitiesService } from '../../services/capabilities.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Primary side navigation.
|
* Primary side navigation.
|
||||||
@@ -80,10 +82,32 @@ const MENU: readonly MenuGroup[] = [
|
|||||||
})
|
})
|
||||||
export class Sidebar {
|
export class Sidebar {
|
||||||
private readonly layout = inject(LayoutStateService);
|
private readonly layout = inject(LayoutStateService);
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly capabilities = inject(CapabilitiesService);
|
||||||
|
|
||||||
protected readonly menu = MENU;
|
protected readonly menu = MENU;
|
||||||
protected readonly collapsed = this.layout.sidebarCollapsed;
|
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(() =>
|
protected readonly toggleAriaLabel = computed(() =>
|
||||||
this.collapsed()
|
this.collapsed()
|
||||||
? $localize`:@@sidebar.toggle.aria.expand:Expand sidebar`
|
? $localize`:@@sidebar.toggle.aria.expand:Expand sidebar`
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { AUTH_BFF_BASE_URL, AuthService } from 'feature-auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curated capabilities view served by the BFF at
|
||||||
|
* `GET /api/me/capabilities` (ADR-0009 amendment in this PR).
|
||||||
|
* Mirrors the BFF's `CapabilitiesView` interface 1:1.
|
||||||
|
*/
|
||||||
|
export interface Capabilities {
|
||||||
|
readonly canAccessAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anonymous default — used while auth state is loading or anonymous. */
|
||||||
|
const ANONYMOUS_CAPABILITIES: Capabilities = { canAccessAdmin: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `CapabilitiesService` — fetches `/api/me/capabilities` whenever the
|
||||||
|
* `AuthService` flips into `authenticated`, exposes the resulting
|
||||||
|
* booleans as signals for the header / sidebar to read.
|
||||||
|
*
|
||||||
|
* Per ADR-0009 §"curated public view" the user-portal `/me`
|
||||||
|
* deliberately omits the raw `roles` claim; the SPA derives binary
|
||||||
|
* UX flags from this companion endpoint instead. Today the only
|
||||||
|
* flag is `canAccessAdmin` — drives the "Open Portal Admin" entry
|
||||||
|
* in the user menu and the "Role: Administrator" label in the
|
||||||
|
* sidebar widget.
|
||||||
|
*
|
||||||
|
* Anonymous traffic doesn't trigger a fetch (the BFF would 401);
|
||||||
|
* the signal stays at the all-false default. The service re-fetches
|
||||||
|
* automatically when sign-in completes thanks to the `effect` on
|
||||||
|
* `auth.currentUser`.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class CapabilitiesService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
|
||||||
|
|
||||||
|
private readonly _capabilities = signal<Capabilities>(ANONYMOUS_CAPABILITIES);
|
||||||
|
|
||||||
|
readonly capabilities = this._capabilities.asReadonly();
|
||||||
|
readonly canAccessAdmin = computed(() => this._capabilities().canAccessAdmin);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Reactive plumbing: whenever the authenticated user signal
|
||||||
|
// changes (initial bootstrap, post-login redirect return, sign-
|
||||||
|
// out), re-resolve the capabilities. The effect resets the
|
||||||
|
// payload to the anonymous default before fetching so a stale
|
||||||
|
// "yes admin" from a previous user session doesn't leak into
|
||||||
|
// the new one.
|
||||||
|
effect(() => {
|
||||||
|
const user = this.auth.currentUser();
|
||||||
|
if (user) {
|
||||||
|
void this.refresh();
|
||||||
|
} else {
|
||||||
|
this._capabilities.set(ANONYMOUS_CAPABILITIES);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const caps = await firstValueFrom(
|
||||||
|
this.http.get<Capabilities>(`${this.bffBaseUrl}/me/capabilities`),
|
||||||
|
);
|
||||||
|
this._capabilities.set(caps);
|
||||||
|
} catch (err) {
|
||||||
|
// 401 here means the session expired between the auth state
|
||||||
|
// bootstrap and our fetch — collapse to anonymous. Any other
|
||||||
|
// error is rare enough (network blip, BFF 500) that conservative
|
||||||
|
// "no extra UI" is the right default; the `AuthService`'s own
|
||||||
|
// 401 interceptor will re-bootstrap the state on the next call.
|
||||||
|
if (err instanceof HttpErrorResponse) {
|
||||||
|
this._capabilities.set(ANONYMOUS_CAPABILITIES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,4 +40,14 @@ export const environment = {
|
|||||||
* `infra/local/otel-collector.yaml`.
|
* `infra/local/otel-collector.yaml`.
|
||||||
*/
|
*/
|
||||||
otlpEndpoint: 'http://localhost:4318/v1/traces',
|
otlpEndpoint: 'http://localhost:4318/v1/traces',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origin of the sibling `portal-admin` SPA — driver of the
|
||||||
|
* cross-app "Open Portal Admin" entry in the user menu (gated on
|
||||||
|
* `CapabilitiesService.canAccessAdmin`). Per ADR-0020 the two
|
||||||
|
* SPAs live on distinct origins / cookies / sessions, so this is
|
||||||
|
* a raw cross-origin `<a href>` jump, not an Angular route.
|
||||||
|
* Per-env siblings override the host.
|
||||||
|
*/
|
||||||
|
adminAppUrl: 'http://localhost:4300',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,6 +80,10 @@
|
|||||||
<source>Sign out</source>
|
<source>Sign out</source>
|
||||||
<target>Se déconnecter</target>
|
<target>Se déconnecter</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="header.userMenu.openAdmin" datatype="html">
|
||||||
|
<source>Open Portal Admin</source>
|
||||||
|
<target>Ouvrir Administration APF Portal</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="header.userMenu.trigger.aria" datatype="html">
|
<trans-unit id="header.userMenu.trigger.aria" datatype="html">
|
||||||
<source>User menu — signed in as <x id="displayName" equiv-text="displayName"/></source>
|
<source>User menu — signed in as <x id="displayName" equiv-text="displayName"/></source>
|
||||||
<target>Menu utilisateur — connecté en tant que <x id="displayName" equiv-text="displayName"/></target>
|
<target>Menu utilisateur — connecté en tant que <x id="displayName" equiv-text="displayName"/></target>
|
||||||
@@ -150,9 +154,17 @@
|
|||||||
<source>Anonymous</source>
|
<source>Anonymous</source>
|
||||||
<target>Anonyme</target>
|
<target>Anonyme</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.role.administrator" datatype="html">
|
||||||
|
<source>Administrator</source>
|
||||||
|
<target>Administrateur</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.role.user" datatype="html">
|
||||||
|
<source>User</source>
|
||||||
|
<target>Utilisateur</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="sidebar.role.aria" datatype="html">
|
<trans-unit id="sidebar.role.aria" datatype="html">
|
||||||
<source>Current role: anonymous (signed-out)</source>
|
<source>Current role: <x id="role" equiv-text="role"/></source>
|
||||||
<target>Rôle actuel : anonyme (non connecté)</target>
|
<target>Rôle actuel : <x id="role" equiv-text="role"/></target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="sidebar.role.label" datatype="html">
|
<trans-unit id="sidebar.role.label" datatype="html">
|
||||||
<source>Role:</source>
|
<source>Role:</source>
|
||||||
|
|||||||
@@ -121,14 +121,17 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi
|
|||||||
**Routes.** All authentication endpoints live under the `/auth` prefix on the BFF:
|
**Routes.** All authentication endpoints live under the `/auth` prefix on the BFF:
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
| ------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters |
|
| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters |
|
||||||
| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) |
|
| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) |
|
||||||
| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL |
|
| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL |
|
||||||
| `GET` | `/auth/me` | returns the current user's id, audience, and a curated subset of claims for the SPA to display (no tokens) |
|
| `GET` | `/auth/me` | returns the current user's id, audience, and a curated subset of claims for the SPA to display (no tokens, no raw `roles` claim — see "curated public view" below) |
|
||||||
|
| `GET` | `/me/capabilities` | curated capabilities view derived from the session (currently `{ canAccessAdmin: boolean }`); drives binary SPA gates without exposing the raw `roles` claim — see "curated public view" below |
|
||||||
|
|
||||||
**Authorization layer.** A NestJS `AuthGuard` checks for a valid session and rejects with 401 otherwise. A `@CurrentUser()` decorator extracts `{ id, audience, claims }` from the request scope. Every controller (other than `/auth/*` itself, `/health`, etc.) is protected by `AuthGuard` by default — the framework default is "denied", explicit allow-listing is required.
|
**Authorization layer.** A NestJS `AuthGuard` checks for a valid session and rejects with 401 otherwise. A `@CurrentUser()` decorator extracts `{ id, audience, claims }` from the request scope. Every controller (other than `/auth/*` itself, `/health`, etc.) is protected by `AuthGuard` by default — the framework default is "denied", explicit allow-listing is required.
|
||||||
|
|
||||||
|
**Curated public view.** 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, consumed by the BFF's own guards (`@RequireAdmin`, downstream-API gates, etc.). The SPA derives binary UX hints from a dedicated companion endpoint, `GET /me/capabilities`, which returns a curated structure (today `{ canAccessAdmin: boolean }`; further keys land as conditional UI requires). The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles (auditor, redactor, …) does not widen the SPA-side surface. The authoritative gate remains BFF-side via `@RequireAdmin` & co.; capabilities is UX-only. The admin-portal `/api/admin/auth/me` exposes `roles` explicitly because it _is_ the admin surface — see ADR-0020.
|
||||||
|
|
||||||
**Configuration.** All Entra-specific values come from environment variables — no tenant ID, client ID, or secret in source. The BFF refuses to start if any required variable is missing.
|
**Configuration.** All Entra-specific values come from environment variables — no tenant ID, client ID, or secret in source. The BFF refuses to start if any required variable is missing.
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|
|||||||
Reference in New Issue
Block a user