feat(portal-admin): profile page on /profile guarded by authGuard (#150)
## Summary PR 2 of 3 from the user-menu / profile / cross-app-link chantier. Lands a `/profile` page on `portal-admin` so the Profile entry the user menu wired in PR 1 stops 404-ing. Portal-shell already had a demo `/profile` route that fits this slot — left as-is rather than churned. | PR | Périmètre | | --- | --- | | PR 1 ✅ | Shared `UserMenu` dropdown + integration. | | **PR 2 (this one)** | `/profile` page on `portal-admin`; `CurrentUser.roles` typed (the BFF already returns it). | | PR 3 | `/api/me/capabilities` + sidebar role widget + cross-app links. | ## What lands ### New page — [`apps/portal-admin/src/app/pages/profile/`](apps/portal-admin/src/app/pages/profile/) Lazy-loaded at `/profile`, guarded by `authGuard` (from `feature-auth`). Two cards: - **Identity** — displayName, username, Entra `oid`, tenant `tid`. The two ids are monospaced + break-anywhere so they don't push the layout on narrow viewports. - **App roles** — chips listing every Entra app-role claim the session carries (`Portal.Admin` for v1, future business roles once ADR-0011 step-up lands its real consumers). Hidden entirely when the `roles` array is empty so anonymous-then-promoted accounts don't see a "no roles" affordance that doesn't help anyone. The `@if (user())` template guard narrows the type so the page is single-branch — `authGuard` already blocked anonymous traffic upstream. Lazy chunk lands at **5.37 KB raw / 1.57 KB gzip** — comfortably under the per-chunk lazy budget. ### Route — [`apps/portal-admin/src/app/app.routes.ts`](apps/portal-admin/src/app/app.routes.ts) ```ts { path: 'profile', canActivate: [authGuard], loadComponent: () => import('./pages/profile/profile').then((m) => m.ProfilePage), title: profileTitle, } ``` `route.profile.title` added to [`messages.fr.xlf`](apps/portal-admin/src/locale/messages.fr.xlf) (FR target retained for parity with the other route titles, even though portal-admin doesn't expose a locale switcher in v1 per ADR-0020). ### Type — [`libs/feature/auth/src/lib/auth.types.ts`](libs/feature/auth/src/lib/auth.types.ts) ```ts export interface CurrentUser { … /** Optional; present on `/api/admin/auth/me`, omitted on `/api/auth/me`. */ readonly roles?: readonly string[]; } ``` `/api/admin/auth/me` already returns `roles` (PR #129), but `CurrentUser` was discarding it through the typed deserialization. Marking the field optional captures the existing BFF response without surfacing the claim on the user portal — ADR-0009's "curated public view" stays intact on `/api/auth/me`, which simply doesn't populate it. ## Notes for the reviewer - **Why not refresh `portal-shell`'s `/profile` too?** The existing demo page already has the same shape (displayName, username, oid, tid) and is functionally fine. Refreshing for parity would be incidental scope creep against PR 2's goal (unblock the Profile menu entry on the admin surface). PR 3 may revisit when it adds the role display widget. - **Why no `User profile` entry in the admin sidebar?** The user menu already exposes Profile and ADR-0020's v1 sidebar catalogue is "modules", not "self-service shortcuts". Adding a sidebar entry would duplicate the user-menu access path and break the rule that the sidebar maps to admin business modules. - **Why a single English-source page, not FR-translated content?** Same posture as the audit + users pages: per ADR-0020 §"No locale switcher in v1", admin chrome stays in source locale. Route titles are i18n-marked because the prod build's `i18nMissingTranslation=error` policy would fail otherwise — the page body text doesn't need to be. - **Why not show roles on the portal-shell side too?** That's PR 3, gated on the `/api/me/capabilities` design (the user picked the dedicated capabilities endpoint over `roles`-on-user-/me). Adding any role-related rendering here would pre-empt that choice. ## Test plan - [x] `pnpm nx test portal-admin` — **50 specs pass** (was 46, +4 for the new `ProfilePage` spec). - [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-ui,shared-state,feature-auth` — 15/15 tasks green, including the i18n-strict `portal-shell:build:production`. - [ ] Manual smoke — sign in on portal-admin, click the avatar → Profile entry, confirm: identity card populated, App roles card shows `Portal.Admin` (if you have the role assigned), Settings entry of the user menu still greyed with the Soon badge. ## What's next PR 3 lands the cross-app machinery — `GET /api/me/capabilities` endpoint, real role surfacing on the user-portal sidebar widget (`Anonymous` → `Authenticated` / role label, no more hardcode), and the symmetric "Open Portal Admin" / "Open Portal Shell" entries in each app's user menu, gated on the capabilities response. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #150
This commit was merged in pull request #150.
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">
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user