feat(portal-admin): profile page on /profile guarded by authGuard
CI / scan (pull_request) Successful in 2m38s
CI / commits (pull_request) Successful in 2m47s
CI / check (pull_request) Successful in 4m31s
CI / a11y (pull_request) Successful in 1m51s
CI / perf (pull_request) Successful in 5m35s

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.
This commit is contained in:
Julien Gautier
2026-05-15 15:41:16 +02:00
parent a8ead65ea8
commit 3b3cd1dc66
7 changed files with 329 additions and 0 deletions
+8
View File
@@ -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 `<base href="/{locale}/">` and the
@@ -0,0 +1,48 @@
@if (user(); as currentUser) {
<section class="profile">
<header class="profile-header">
<h1 class="title">My profile</h1>
<p class="intro">
Identity served by the BFF from the active admin session. Read-only — role assignments are
managed in the Entra Admin Center.
</p>
</header>
<article class="card" aria-labelledby="identity-heading">
<h2 id="identity-heading" class="card-title">Identity</h2>
<dl class="grid">
<div>
<dt>Display name</dt>
<dd data-testid="profile-displayname">{{ currentUser.displayName }}</dd>
</div>
<div>
<dt>Username</dt>
<dd data-testid="profile-username">{{ currentUser.username }}</dd>
</div>
<div>
<dt>Entra object id</dt>
<dd class="mono" data-testid="profile-oid">{{ currentUser.oid }}</dd>
</div>
<div>
<dt>Tenant id</dt>
<dd class="mono" data-testid="profile-tid">{{ currentUser.tid }}</dd>
</div>
</dl>
</article>
@if (currentUser.roles && currentUser.roles.length > 0) {
<article class="card" aria-labelledby="roles-heading">
<h2 id="roles-heading" class="card-title">App roles</h2>
<p class="card-intro">
Roles assigned on the BFF's Entra app registration. Drives access to
<code>/api/admin/*</code> through <code>&#64;RequireAdmin</code>.
</p>
<ul class="roles" data-testid="profile-roles">
@for (role of currentUser.roles; track role) {
<li class="role-chip">{{ role }}</li>
}
</ul>
</article>
}
</section>
}
@@ -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;
}
@@ -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();
});
});
@@ -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;
}
@@ -21,6 +21,10 @@
<source>Users — APF Portal Admin</source>
<target>Utilisateurs — Administration APF Portal</target>
</trans-unit>
<trans-unit id="route.profile.title" datatype="html">
<source>Profile — APF Portal Admin</source>
<target>Profil — Administration APF Portal</target>
</trans-unit>
<!-- page.home -->
<trans-unit id="page.home.title" datatype="html">