From 3b3cd1dc66305de11e40cd7f69f32bbbfae49d10 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Fri, 15 May 2026 15:41:16 +0200 Subject: [PATCH] feat(portal-admin): profile page on /profile guarded by authGuard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2 of 3 from the user-menu chantier. * New lazy-loaded `/profile` route in `apps/portal-admin`. Renders the active admin session's identity card (displayName, username, oid, tid) plus an "App roles" card listing the Entra app-role claims attached to the session — `Portal.Admin` today, additional business roles when the strategic security baseline ADR lands. * `authGuard` (from `feature-auth`) gates the route — anonymous visitors bounce through the BFF's `/api/admin/auth/login`. The template's `@if (user())` then narrows the type so the page can drop the anonymous branch. * The `User Menu`'s Profile entry (PR 1) already points at `/profile` so the link starts working on this surface as soon as this lands. * `CurrentUser.roles` extended with an optional `readonly string[]` — the admin-side `/api/admin/auth/me` already returns the claim; the type now captures it. User-portal `/me` keeps omitting it per ADR-0009 (PR 3 surfaces it via the dedicated capabilities endpoint). * Lazy chunk lands at 5.37 KB raw / 1.57 KB gzip — comfortably under the lazy-chunk budget. --- apps/portal-admin/src/app/app.routes.ts | 8 + .../src/app/pages/profile/profile.html | 48 ++++++ .../src/app/pages/profile/profile.scss | 150 ++++++++++++++++++ .../src/app/pages/profile/profile.spec.ts | 86 ++++++++++ .../src/app/pages/profile/profile.ts | 25 +++ apps/portal-admin/src/locale/messages.fr.xlf | 4 + libs/feature/auth/src/lib/auth.types.ts | 8 + 7 files changed, 329 insertions(+) create mode 100644 apps/portal-admin/src/app/pages/profile/profile.html create mode 100644 apps/portal-admin/src/app/pages/profile/profile.scss create mode 100644 apps/portal-admin/src/app/pages/profile/profile.spec.ts create mode 100644 apps/portal-admin/src/app/pages/profile/profile.ts diff --git a/apps/portal-admin/src/app/app.routes.ts b/apps/portal-admin/src/app/app.routes.ts index e5cf2e9..646c8ce 100644 --- a/apps/portal-admin/src/app/app.routes.ts +++ b/apps/portal-admin/src/app/app.routes.ts @@ -1,8 +1,10 @@ import { Route } from '@angular/router'; +import { authGuard } from 'feature-auth'; const homeTitle = $localize`:@@route.home.title:APF Portal Admin`; const auditTitle = $localize`:@@route.audit.title:Audit log — APF Portal Admin`; const usersTitle = $localize`:@@route.users.title:Users — APF Portal Admin`; +const profileTitle = $localize`:@@route.profile.title:Profile — APF Portal Admin`; export const appRoutes: Route[] = [ { @@ -21,6 +23,12 @@ export const appRoutes: Route[] = [ loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage), title: usersTitle, }, + { + path: 'profile', + canActivate: [authGuard], + loadComponent: () => import('./pages/profile/profile').then((m) => m.ProfilePage), + title: profileTitle, + }, // Catch-all — unknown paths bounce to home. Same role as the // wildcard in portal-shell (per PR #96): in production each locale // bundle ships with its own `` and the diff --git a/apps/portal-admin/src/app/pages/profile/profile.html b/apps/portal-admin/src/app/pages/profile/profile.html new file mode 100644 index 0000000..c9a7179 --- /dev/null +++ b/apps/portal-admin/src/app/pages/profile/profile.html @@ -0,0 +1,48 @@ +@if (user(); as currentUser) { +
+
+

My profile

+

+ Identity served by the BFF from the active admin session. Read-only — role assignments are + managed in the Entra Admin Center. +

+
+ +
+

Identity

+
+
+
Display name
+
{{ currentUser.displayName }}
+
+
+
Username
+
{{ currentUser.username }}
+
+
+
Entra object id
+
{{ currentUser.oid }}
+
+
+
Tenant id
+
{{ currentUser.tid }}
+
+
+
+ + @if (currentUser.roles && currentUser.roles.length > 0) { +
+

App roles

+

+ Roles assigned on the BFF's Entra app registration. Drives access to + /api/admin/* through @RequireAdmin. +

+
    + @for (role of currentUser.roles; track role) { +
  • {{ role }}
  • + } +
+
+ } +
+} diff --git a/apps/portal-admin/src/app/pages/profile/profile.scss b/apps/portal-admin/src/app/pages/profile/profile.scss new file mode 100644 index 0000000..488a25f --- /dev/null +++ b/apps/portal-admin/src/app/pages/profile/profile.scss @@ -0,0 +1,150 @@ +.profile { + max-width: 720px; + margin: 0 auto; +} + +.profile-header { + margin-bottom: 1.5rem; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.25rem; +} + +.intro { + margin: 0; + font-size: 0.875rem; + color: #6b7280; +} + +:host-context(.dark) .intro { + color: #9ca3af; +} + +.card { + padding: 1rem 1.25rem; + margin-bottom: 1rem; + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; +} + +:host-context(.dark) .card { + background-color: #111827; + border-color: #1f2937; +} + +.card-title { + margin: 0 0 0.75rem; + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; +} + +:host-context(.dark) .card-title { + color: #9ca3af; +} + +.card-intro { + margin: 0 0 0.75rem; + font-size: 0.875rem; + color: #4b5563; + + code { + padding: 0.0625rem 0.25rem; + border-radius: 0.25rem; + background-color: rgba(0, 0, 0, 0.05); + font-family: ui-monospace, monospace; + font-size: 0.875em; + } +} + +:host-context(.dark) .card-intro { + color: #d1d5db; + + code { + background-color: rgba(255, 255, 255, 0.08); + } +} + +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.875rem 1.25rem; + margin: 0; + + @media (max-width: 540px) { + grid-template-columns: 1fr; + } + + > div { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + dt { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; + font-weight: 500; + } + + dd { + margin: 0; + font-size: 0.875rem; + color: #111827; + } + + dd.mono { + font-family: ui-monospace, monospace; + font-size: 0.75rem; + color: #374151; + word-break: break-all; + } +} + +:host-context(.dark) .grid { + dt { + color: #9ca3af; + } + + dd { + color: #f3f4f6; + } + + dd.mono { + color: #d1d5db; + } +} + +.roles { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.role-chip { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.625rem; + border-radius: 999px; + background-color: rgba(29, 78, 216, 0.1); + color: var(--color-brand-primary-700, #1e40af); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +:host-context(.dark) .role-chip { + background-color: rgba(96, 165, 250, 0.16); + color: #bfdbfe; +} diff --git a/apps/portal-admin/src/app/pages/profile/profile.spec.ts b/apps/portal-admin/src/app/pages/profile/profile.spec.ts new file mode 100644 index 0000000..797bda7 --- /dev/null +++ b/apps/portal-admin/src/app/pages/profile/profile.spec.ts @@ -0,0 +1,86 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { + AUTH_BFF_BASE_URL, + AUTH_NAVIGATOR, + AUTH_PATH_PREFIX, + type CurrentUser, +} from 'feature-auth'; +import { ProfilePage } from './profile'; + +const BFF_BASE = 'http://bff.test/api'; +const ADMIN_ME_URL = `${BFF_BASE}/admin/auth/me`; + +const USER: CurrentUser = { + oid: 'admin-oid-abc', + tid: 'tenant-1', + username: 'admin@apf.example', + displayName: 'Admin Smith', + roles: ['Portal.Admin'], +}; + +async function setup(user: CurrentUser | null) { + TestBed.configureTestingModule({ + imports: [ProfilePage], + providers: [ + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE }, + { provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' }, + { provide: AUTH_NAVIGATOR, useValue: vi.fn() }, + ], + }); + const fixture = TestBed.createComponent(ProfilePage); + const http = TestBed.inject(HttpTestingController); + const req = http.expectOne(ADMIN_ME_URL); + if (user) { + req.flush(user); + } else { + req.flush({}, { status: 401, statusText: 'Unauthorized' }); + } + await Promise.resolve(); + fixture.detectChanges(); + await fixture.whenStable(); + return { fixture, http }; +} + +describe('ProfilePage (portal-admin)', () => { + beforeEach(() => TestBed.resetTestingModule()); + + it('renders the identity card with displayName, username, oid, tid', async () => { + const { fixture } = await setup(USER); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('[data-testid="profile-displayname"]')?.textContent?.trim()).toBe( + 'Admin Smith', + ); + expect(root.querySelector('[data-testid="profile-username"]')?.textContent?.trim()).toBe( + 'admin@apf.example', + ); + expect(root.querySelector('[data-testid="profile-oid"]')?.textContent?.trim()).toBe( + 'admin-oid-abc', + ); + expect(root.querySelector('[data-testid="profile-tid"]')?.textContent?.trim()).toBe('tenant-1'); + }); + + it('renders the App roles card with one chip per role when present', async () => { + const { fixture } = await setup({ ...USER, roles: ['Portal.Admin', 'Portal.Auditor'] }); + const root = fixture.nativeElement as HTMLElement; + const chips = Array.from(root.querySelectorAll('[data-testid="profile-roles"] .role-chip')); + expect(chips.map((el) => el.textContent?.trim())).toEqual(['Portal.Admin', 'Portal.Auditor']); + }); + + it('omits the App roles card entirely when the session carries no roles', async () => { + const { fixture } = await setup({ ...USER, roles: [] }); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('[data-testid="profile-roles"]')).toBeNull(); + }); + + it('renders nothing when the user is anonymous (template @if guard)', async () => { + const { fixture } = await setup(null); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('.profile')).toBeNull(); + }); +}); diff --git a/apps/portal-admin/src/app/pages/profile/profile.ts b/apps/portal-admin/src/app/pages/profile/profile.ts new file mode 100644 index 0000000..0325d56 --- /dev/null +++ b/apps/portal-admin/src/app/pages/profile/profile.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { AuthService } from 'feature-auth'; + +/** + * Admin-portal profile page. Lazy-loaded at `/profile`, gated by + * `authGuard` so `AuthService.currentUser()` is guaranteed non-null + * by the time the template runs. + * + * Carries more identity surface than the `portal-shell` counterpart + * because the admin-side `/api/admin/auth/me` includes the `roles` + * claim (admin SPA renders role badges, drives admin-only UI). The + * user-portal page deliberately skips `roles` to stay aligned with + * ADR-0009 §"curated public view" — see `CurrentUser` for the field + * shape. + */ +@Component({ + selector: 'app-profile-page', + templateUrl: './profile.html', + styleUrl: './profile.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfilePage { + private readonly auth = inject(AuthService); + protected readonly user = this.auth.currentUser; +} diff --git a/apps/portal-admin/src/locale/messages.fr.xlf b/apps/portal-admin/src/locale/messages.fr.xlf index 463a920..07ce70e 100644 --- a/apps/portal-admin/src/locale/messages.fr.xlf +++ b/apps/portal-admin/src/locale/messages.fr.xlf @@ -21,6 +21,10 @@ Users — APF Portal Admin Utilisateurs — Administration APF Portal + + Profile — APF Portal Admin + Profil — Administration APF Portal + diff --git a/libs/feature/auth/src/lib/auth.types.ts b/libs/feature/auth/src/lib/auth.types.ts index 0cfa6c4..b2e2497 100644 --- a/libs/feature/auth/src/lib/auth.types.ts +++ b/libs/feature/auth/src/lib/auth.types.ts @@ -9,6 +9,14 @@ export interface CurrentUser { readonly tid: string; readonly username: string; readonly displayName: string; + /** + * Entra app roles assigned on the BFF's app registration. Present + * on `/api/admin/auth/me` (admin portal) — the admin SPA uses it + * to surface role badges + admin-only UI. Omitted on the user + * portal's `/api/auth/me` per ADR-0009 §"curated public view"; the + * shell consults `/api/me/capabilities` for binary access hints. + */ + readonly roles?: readonly string[]; } /** -- 2.30.2