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 { 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>@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">
|
||||
|
||||
Reference in New Issue
Block a user