feat(portal-admin): profile page on /profile guarded by authGuard
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:
@@ -1,8 +1,10 @@
|
|||||||
import { Route } from '@angular/router';
|
import { Route } from '@angular/router';
|
||||||
|
import { authGuard } from 'feature-auth';
|
||||||
|
|
||||||
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
|
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
|
||||||
const auditTitle = $localize`:@@route.audit.title:Audit log — 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 usersTitle = $localize`:@@route.users.title:Users — APF Portal Admin`;
|
||||||
|
const profileTitle = $localize`:@@route.profile.title:Profile — APF Portal Admin`;
|
||||||
|
|
||||||
export const appRoutes: Route[] = [
|
export const appRoutes: Route[] = [
|
||||||
{
|
{
|
||||||
@@ -21,6 +23,12 @@ export const appRoutes: Route[] = [
|
|||||||
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),
|
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),
|
||||||
title: usersTitle,
|
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
|
// Catch-all — unknown paths bounce to home. Same role as the
|
||||||
// wildcard in portal-shell (per PR #96): in production each locale
|
// wildcard in portal-shell (per PR #96): in production each locale
|
||||||
// bundle ships with its own `<base href="/{locale}/">` and the
|
// 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>@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>
|
<source>Users — APF Portal Admin</source>
|
||||||
<target>Utilisateurs — Administration APF Portal</target>
|
<target>Utilisateurs — Administration APF Portal</target>
|
||||||
</trans-unit>
|
</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 -->
|
<!-- page.home -->
|
||||||
<trans-unit id="page.home.title" datatype="html">
|
<trans-unit id="page.home.title" datatype="html">
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export interface CurrentUser {
|
|||||||
readonly tid: string;
|
readonly tid: string;
|
||||||
readonly username: string;
|
readonly username: string;
|
||||||
readonly displayName: 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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user