feat(portal-admin): spa auth wiring + admin shell skeleton #134

Merged
julien merged 1 commits from feat/portal-admin-spa-auth-shell into main 2026-05-14 17:00:39 +02:00
27 changed files with 1085 additions and 44 deletions
+44 -2
View File
@@ -1,7 +1,49 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
provideZonelessChangeDetection,
} from '@angular/core';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import {
AUTH_BFF_BASE_URL,
AUTH_CSRF_COOKIE_NAME,
AUTH_PATH_PREFIX,
bffCredentialsInterceptor,
bffUnauthorizedInterceptor,
csrfInterceptor,
} from 'feature-auth';
import { environment } from '../environments/environment';
import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(appRoutes)],
providers: [
provideZonelessChangeDetection(),
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
// `withFetch()` makes Angular's HttpClient delegate to the
// browser `fetch` API — what
// `@opentelemetry/instrumentation-fetch` patches, so every
// HttpClient request gets its own span and W3C `traceparent`
// header automatically.
//
// Same interceptor chain as portal-shell (per ADR-0009 / ADR-0021):
// - bffCredentialsInterceptor: withCredentials on BFF URLs.
// - bffUnauthorizedInterceptor: refresh AuthService on 401.
// - csrfInterceptor: echo CSRF cookie on mutating requests.
provideHttpClient(
withFetch(),
withInterceptors([bffCredentialsInterceptor, csrfInterceptor, bffUnauthorizedInterceptor]),
),
// `feature-auth` is environment-agnostic. portal-admin diverges
// from portal-shell on a single token: AUTH_PATH_PREFIX. The
// BFF mounts the admin OIDC routes under `/api/admin/auth/*`
// (per ADR-0020 §"Sessions — distinct from `portal-shell`"), so
// the AuthService composes `${bffApiBaseUrl}/admin/auth/{me,login,logout}`.
// The session middleware on the BFF resolves `/api/admin/*` to
// the distinct `__Host-portal_admin_session` cookie.
{ provide: AUTH_BFF_BASE_URL, useValue: environment.bffApiBaseUrl },
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
{ provide: AUTH_CSRF_COOKIE_NAME, useValue: environment.bffCsrfCookieName },
],
};
+8 -3
View File
@@ -1,4 +1,9 @@
<a class="skip-link" href="#main-content" i18n="@@app.skipLink">Skip to main content</a>
<main id="main-content" tabindex="-1" class="min-h-screen bg-gray-50 dark:bg-gray-950">
<router-outlet />
</main>
<app-admin-header />
<div class="shell-body">
<app-admin-sidebar />
<main id="main-content" tabindex="-1" class="shell-main">
<router-outlet />
</main>
</div>
<app-admin-footer />
+32
View File
@@ -1,3 +1,35 @@
// Admin shell: full-viewport flex column — header on top, sidebar +
// main side-by-side filling the middle, footer pinned at the bottom.
// The sidebar owns its own scrolling so a long nav doesn't push the
// header off-screen; main scrolls independently so audit-log tables
// don't drag the chrome with them.
:host {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
}
.shell-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.shell-main {
flex: 1 1 auto;
min-width: 0;
overflow-y: auto;
background-color: #f9fafb;
color: #111827;
padding: 1.5rem;
}
:host-context(.dark) .shell-main {
background-color: #0b1220;
color: #e5e7eb;
}
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
// visible and focusable when reached via Tab from the address bar.
// Plain CSS rather than the `sr-only` Tailwind utilities so the
+15 -2
View File
@@ -1,20 +1,33 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AUTH_BFF_BASE_URL, AUTH_CSRF_COOKIE_NAME, AUTH_NAVIGATOR } from 'feature-auth';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: 'http://bff.test/api' },
{ provide: AUTH_CSRF_COOKIE_NAME, useValue: 'portal_csrf' },
{ provide: AUTH_NAVIGATOR, useValue: vi.fn() },
],
}).compileComponents();
});
it('renders the layout shell — skip link, main landmark', async () => {
it('renders the admin shell — skip link, header, sidebar, main landmark, footer', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('a.skip-link')).not.toBeNull();
expect(root.querySelector('main#main-content')).not.toBeNull();
expect(root.querySelector('app-admin-header')).not.toBeNull();
expect(root.querySelector('app-admin-sidebar')).not.toBeNull();
expect(root.querySelector('app-admin-footer')).not.toBeNull();
});
});
+4 -1
View File
@@ -1,9 +1,12 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AdminFooter } from './components/footer/footer';
import { AdminHeader } from './components/header/header';
import { AdminSidebar } from './components/sidebar/sidebar';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
imports: [RouterOutlet, AdminHeader, AdminSidebar, AdminFooter],
templateUrl: './app.html',
styleUrl: './app.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -0,0 +1,4 @@
<footer class="admin-footer">
<span class="copyright">© {{ year }} APF France handicap</span>
<span class="surface-tag">Admin surface</span>
</footer>
@@ -0,0 +1,29 @@
.admin-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
height: 2.5rem;
padding: 0 1rem;
background-color: #f9fafb;
border-top: 1px solid #e5e7eb;
color: #6b7280;
font-size: 0.75rem;
}
:host-context(.dark) .admin-footer {
background-color: #0f172a;
border-top-color: #1f2937;
color: #9ca3af;
}
.surface-tag {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-brand-accent-700, #b45309);
}
:host-context(.dark) .surface-tag {
color: var(--color-brand-accent-300, #fbbf24);
}
@@ -0,0 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { AdminFooter } from './footer';
describe('AdminFooter', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [AdminFooter] }).compileComponents();
});
function render(): HTMLElement {
const fixture = TestBed.createComponent(AdminFooter);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
it('renders the APF copyright with the current year', () => {
const root = render();
const copy = root.querySelector('.copyright')?.textContent ?? '';
expect(copy).toContain('APF France handicap');
expect(copy).toContain(String(new Date().getFullYear()));
});
it('renders the "Admin surface" tag as a persistent visual reminder', () => {
const root = render();
expect(root.querySelector('.surface-tag')?.textContent?.trim()).toBe('Admin surface');
});
});
@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
/**
* Minimal admin-portal footer. Sits flush at the bottom of the
* shell with the copyright + a small "Admin" surface tag — same
* visual reminder as the header badge, since the footer is in
* view for any sufficiently long workload (audit-log paging,
* CMS editing) where the header may have scrolled out.
*
* No locale switcher, no accessibility-statement link in v1 — both
* land when the admin app gets full i18n / a11y plumbing of its
* own (currently inherited from the portal-shell baseline only).
*/
@Component({
selector: 'app-admin-footer',
templateUrl: './footer.html',
styleUrl: './footer.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminFooter {
protected readonly year = new Date().getFullYear();
}
@@ -0,0 +1,27 @@
<header class="admin-header">
<a class="brand" routerLink="/">
<span class="brand-wordmark">APF Portal</span>
<span class="brand-badge">Admin</span>
</a>
<div class="auth-widget">
@switch (authState().kind) { @case ('loading') {
<span class="auth-status auth-status--loading" aria-live="polite"></span>
} @case ('anonymous') {
<button type="button" class="btn btn--primary" (click)="signIn()">Sign in</button>
} @case ('error') {
<button
type="button"
class="btn btn--secondary"
(click)="signIn()"
aria-label="Session unavailable — sign in again"
>
Sign in
</button>
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated') {
<span class="avatar" [attr.aria-label]="state.user.displayName">{{ initials() }}</span>
<span class="auth-name">{{ state.user.displayName }}</span>
<button type="button" class="btn btn--secondary" (click)="signOut()">Sign out</button>
} } } }
</div>
</header>
@@ -0,0 +1,110 @@
// Lean admin header — single flex row, no collapse animation, no
// nested grids. Matches the `LayoutStateService`-driven sidebar's
// rail metrics so the brand sits flush above the navigation column.
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
height: 3.5rem;
padding: 0 1rem;
background-color: var(--color-brand-primary-600, #1d4ed8);
color: #ffffff;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: inherit;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
letter-spacing: 0.01em;
&:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
border-radius: 0.25rem;
}
}
// "Admin" pill — distinct color from the wordmark so it reads as a
// status badge even in dense headers. Keeps the surface
// immediately recognisable per ADR-0020 §"Discoverability for
// auditors".
.brand-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
background-color: var(--color-brand-accent-300, #f59e0b);
color: #1f2937;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: 0.25rem;
}
.auth-widget {
display: flex;
align-items: center;
gap: 0.5rem;
}
.auth-status--loading {
font-size: 0.875rem;
opacity: 0.8;
}
.auth-name {
font-size: 0.875rem;
font-weight: 500;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.2);
color: inherit;
font-size: 0.75rem;
font-weight: 600;
}
.btn {
display: inline-flex;
align-items: center;
height: 2rem;
padding: 0 0.75rem;
border: 1px solid transparent;
border-radius: 0.25rem;
background-color: transparent;
color: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
&:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
}
.btn--primary {
background-color: var(--color-brand-accent-300, #f59e0b);
color: #1f2937;
border-color: transparent;
}
.btn--secondary {
background-color: transparent;
border-color: rgba(255, 255, 255, 0.4);
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
@@ -0,0 +1,93 @@
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 { AdminHeader } from './header';
const BFF_BASE = 'http://bff.test/api';
const ADMIN_ME_URL = `${BFF_BASE}/admin/auth/me`;
const USER: CurrentUser = {
oid: 'admin-oid',
tid: 'tenant-1',
username: 'admin@apf.example',
displayName: 'Admin Smith',
};
async function setup(opts: { onBootstrap: 'authenticated' | 'anonymous' | 'leave' }) {
const navigate = vi.fn();
TestBed.configureTestingModule({
imports: [AdminHeader],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
{ provide: AUTH_NAVIGATOR, useValue: navigate },
],
});
const fixture = TestBed.createComponent(AdminHeader);
const http = TestBed.inject(HttpTestingController);
// AuthService fires the bootstrap /me from its constructor. Flush
// it before the first whenStable so the signal update has settled
// by the time we assert on the rendered DOM. `leave` keeps the
// loading state visible for tests that need it.
if (opts.onBootstrap !== 'leave') {
const req = http.expectOne(ADMIN_ME_URL);
if (opts.onBootstrap === 'authenticated') {
req.flush(USER);
} else {
req.flush({}, { status: 401, statusText: 'Unauthorized' });
}
await Promise.resolve();
}
fixture.detectChanges();
await fixture.whenStable();
return { fixture, http, navigate };
}
describe('AdminHeader', () => {
it('renders the wordmark + admin badge', async () => {
const { fixture } = await setup({ onBootstrap: 'anonymous' });
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.brand-wordmark')?.textContent).toContain('APF Portal');
expect(root.querySelector('.brand-badge')?.textContent?.trim()).toBe('Admin');
});
it('shows a Sign in button when anonymous', async () => {
const { fixture } = await setup({ onBootstrap: 'anonymous' });
const button = (fixture.nativeElement as HTMLElement).querySelector('.btn--primary');
expect(button?.textContent?.trim()).toBe('Sign in');
});
it('shows the user widget with initials when authenticated', async () => {
const { fixture } = await setup({ onBootstrap: 'authenticated' });
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.avatar')?.textContent?.trim()).toBe('AS');
expect(root.querySelector('.auth-name')?.textContent).toContain('Admin Smith');
expect(root.querySelector('.btn--secondary')?.textContent?.trim()).toBe('Sign out');
});
it('navigates to the admin login URL on Sign in click', async () => {
const { fixture, navigate } = await setup({ onBootstrap: 'anonymous' });
(fixture.nativeElement as HTMLElement)
.querySelector<HTMLButtonElement>('.btn--primary')
?.click();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/login`);
});
it('navigates to the admin logout URL on Sign out click', async () => {
const { fixture, navigate } = await setup({ onBootstrap: 'authenticated' });
(fixture.nativeElement as HTMLElement)
.querySelector<HTMLButtonElement>('.btn--secondary')
?.click();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/logout`);
});
});
@@ -0,0 +1,61 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthService, type CurrentUser } from 'feature-auth';
/**
* Admin-portal header. Deliberately leaner than the user-portal
* counterpart per ADR-0020 §"UX style is data-dense" — admins land
* on tabular workloads, not a discovery dashboard, so we drop the
* global search box, notification cluster, and help widget for v1.
*
* Two anchor points:
* - The "Admin" badge next to the wordmark, so an internal user
* instantly sees they're on the elevated surface (defense
* against "thought I was on portal-shell, just deleted a CMS
* page" muscle-memory misfires).
* - The auth widget: signed in → avatar with initials + Sign out,
* signed out → a Sign in button that 302s through the admin
* OIDC flow (`/api/admin/auth/login`). State driven by the
* shared `feature-auth` `AuthService` with `AUTH_PATH_PREFIX`
* overridden to `/admin/auth` (see `app.config.ts`).
*
* No locale switcher in v1 — admin users are a small known set
* and the source-locale-only chrome keeps the bundle leaner.
* Promotion when the editorial team needs FR/EN parity in the
* admin UI itself.
*/
@Component({
selector: 'app-admin-header',
imports: [RouterLink],
templateUrl: './header.html',
styleUrl: './header.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminHeader {
private readonly auth = inject(AuthService);
protected readonly authState = this.auth.state;
protected readonly initials = computed(() => {
const user = this.auth.currentUser();
return user ? initialsFor(user) : '';
});
protected signIn(): void {
this.auth.login();
}
protected signOut(): void {
this.auth.logout();
}
}
function initialsFor(user: CurrentUser): string {
const name = (user.displayName || user.username).trim();
if (!name) {
return '?';
}
const parts = name.split(/\s+/).filter(Boolean).slice(0, 2);
const letters = parts.map((p) => p.charAt(0).toUpperCase()).join('');
return letters || name.slice(0, 2).toUpperCase();
}
@@ -0,0 +1,25 @@
<nav class="admin-sidebar" aria-label="Admin navigation">
<ul class="nav-list">
@for (item of menu; track item.label) {
<li class="nav-item">
@if (item.routerLink !== null) {
<a
class="nav-link"
[routerLink]="item.routerLink"
routerLinkActive="nav-link--active"
[routerLinkActiveOptions]="{ exact: false }"
>
<span class="nav-icon"><lib-icon [name]="item.icon" [size]="18" /></span>
<span class="nav-label">{{ item.label }}</span>
</a>
} @else {
<span class="nav-link nav-link--disabled" aria-disabled="true">
<span class="nav-icon"><lib-icon [name]="item.icon" [size]="18" /></span>
<span class="nav-label">{{ item.label }}</span>
<span class="nav-badge">Soon</span>
</span>
}
</li>
}
</ul>
</nav>
@@ -0,0 +1,102 @@
.admin-sidebar {
width: 16rem;
flex: 0 0 16rem;
background-color: #f3f4f6;
border-right: 1px solid #e5e7eb;
padding: 0.75rem 0;
overflow-y: auto;
}
:host-context(.dark) .admin-sidebar {
background-color: #0f172a;
border-right-color: #1f2937;
color: #e5e7eb;
}
.nav-list {
list-style: none;
margin: 0;
padding: 0;
}
.nav-item {
padding: 0 0.5rem;
}
.nav-link {
display: inline-flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
color: #1f2937;
text-decoration: none;
font-size: 0.875rem;
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
}
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
}
:host-context(.dark) .nav-link {
color: #e5e7eb;
&:hover {
background-color: rgba(255, 255, 255, 0.06);
}
}
.nav-link--active {
background-color: rgba(29, 78, 216, 0.12);
color: var(--color-brand-primary-700, #1e40af);
font-weight: 600;
}
:host-context(.dark) .nav-link--active {
background-color: rgba(96, 165, 250, 0.18);
color: #93c5fd;
}
.nav-link--disabled {
color: #9ca3af;
cursor: default;
&:hover {
background-color: transparent;
}
}
:host-context(.dark) .nav-link--disabled {
color: #6b7280;
}
.nav-icon {
display: inline-flex;
width: 1.25rem;
flex: 0 0 1.25rem;
}
.nav-label {
flex: 1 1 auto;
}
.nav-badge {
font-size: 0.625rem;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.05em;
padding: 0.125rem 0.375rem;
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.06);
color: #6b7280;
}
:host-context(.dark) .nav-badge {
background-color: rgba(255, 255, 255, 0.08);
}
@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AdminSidebar } from './sidebar';
describe('AdminSidebar', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminSidebar],
providers: [provideRouter([])],
}).compileComponents();
});
function render(): HTMLElement {
const fixture = TestBed.createComponent(AdminSidebar);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
it('renders a navigation landmark labelled "Admin navigation"', () => {
const root = render();
const nav = root.querySelector('nav');
expect(nav?.getAttribute('aria-label')).toBe('Admin navigation');
});
it('exposes the Audit log entry as a live router link', () => {
const root = render();
const links = Array.from(root.querySelectorAll<HTMLAnchorElement>('a.nav-link'));
const auditLink = links.find((a) => a.textContent?.includes('Audit log'));
expect(auditLink).toBeTruthy();
// RouterLink renders the resolved href on the anchor.
expect(auditLink?.getAttribute('href')).toBe('/audit');
});
it('renders the not-yet-implemented entries as aria-disabled placeholders with a "Soon" badge', () => {
const root = render();
const disabled = Array.from(root.querySelectorAll('.nav-link--disabled'));
expect(disabled.length).toBeGreaterThan(0);
for (const node of disabled) {
expect(node.getAttribute('aria-disabled')).toBe('true');
expect(node.querySelector('.nav-badge')?.textContent?.trim()).toBe('Soon');
}
});
});
@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { Icon, type IconName } from 'shared-ui';
/**
* Admin-side navigation. v1 lists the four modules from ADR-0020's
* v1 admin catalogue (CMS, menu management, user list, audit log
* viewer). Only the audit-log link points at a live route in this
* PR; the rest land as routerLink placeholders disabled with
* `aria-disabled`, so the navigation shape is established even
* before the modules ship.
*
* No collapse mode (the portal-admin v1 layout doesn't need to
* compete with content for screen real estate the way the user
* portal does). When that changes we promote the
* `LayoutStateService.sidebarCollapsed` signal here too.
*/
interface AdminMenuItem {
readonly label: string;
readonly icon: IconName;
/** Live route once the module ships; placeholder when null. */
readonly routerLink: string | null;
}
const MENU: readonly AdminMenuItem[] = [
{ label: 'Audit log', icon: 'file-text', routerLink: '/audit' },
{ label: 'CMS pages', icon: 'folder', routerLink: null },
{ label: 'Menu management', icon: 'layout-dashboard', routerLink: null },
{ label: 'User list', icon: 'building-2', routerLink: null },
];
@Component({
selector: 'app-admin-sidebar',
imports: [RouterLink, RouterLinkActive, Icon],
templateUrl: './sidebar.html',
styleUrl: './sidebar.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminSidebar {
protected readonly menu = MENU;
}
+38 -21
View File
@@ -1,24 +1,41 @@
<section class="mx-auto max-w-3xl px-6 py-12">
<h1
class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
i18n="@@page.home.title"
>
APF Portal Admin
</h1>
<p
class="mt-4 text-base leading-relaxed text-gray-700 dark:text-gray-300"
i18n="@@page.home.intro"
>
Administrative back-office for the APF Portal. The first functional modules — content
management, menu administration, user list, audit log viewer — land in upcoming PRs per
ADR-0020.
<section class="home">
<h1 class="title" i18n="@@page.home.title">APF Portal Admin</h1>
<p class="intro">
Administrative back-office for the APF Portal. Sign in with an Entra account that carries the
<code>admin</code> app role to reach the functional modules.
</p>
<p
class="mt-6 inline-flex items-center rounded-full border border-brand-accent-300 bg-brand-accent-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-brand-accent-700 dark:border-brand-accent-700 dark:bg-brand-accent-950/40 dark:text-brand-accent-200"
role="status"
i18n="@@page.home.status"
>
Skeleton — coming soon
</p>
<article class="auth-card" aria-live="polite">
@switch (state().kind) { @case ('loading') {
<p class="status-line">Checking your admin session…</p>
} @case ('anonymous') {
<p class="status-line">No admin session detected.</p>
<button type="button" class="btn btn--primary" (click)="signIn()">Sign in via Entra</button>
} @case ('error') {
<p class="status-line status-line--error">Could not reach the BFF to resolve the session.</p>
<button type="button" class="btn btn--secondary" (click)="signIn()">Try sign in</button>
} @case ('authenticated') { @if (currentUser(); as user) {
<p class="status-line status-line--ok">Signed in as <strong>{{ user.displayName }}</strong>.</p>
<dl class="session-detail">
<dt>Username</dt>
<dd>{{ user.username }}</dd>
<dt>Tenant</dt>
<dd>{{ user.tid }}</dd>
<dt>OID</dt>
<dd>{{ user.oid }}</dd>
</dl>
} } }
</article>
<section class="roadmap" aria-labelledby="roadmap-heading">
<h2 id="roadmap-heading" class="roadmap-heading">Coming next</h2>
<ul class="roadmap-list">
<li>
<strong>Audit log viewer</strong> — paginated, filterable view of <code>audit.events</code>.
</li>
<li><strong>CMS pages</strong> — CRUD on editorial content per locale (ADR-0019).</li>
<li><strong>Menu management</strong> — toggle items + role-gate them.</li>
<li><strong>User list</strong> — read-only directory derived from sign-in events.</li>
</ul>
</section>
</section>
@@ -0,0 +1,153 @@
.home {
max-width: 56rem;
margin: 0 auto;
}
.title {
font-size: 1.875rem;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
}
.intro {
margin: 0.75rem 0 1.5rem;
font-size: 1rem;
line-height: 1.6;
color: #4b5563;
code {
padding: 0.0625rem 0.25rem;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.05);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.875em;
}
}
:host-context(.dark) .intro {
color: #d1d5db;
code {
background-color: rgba(255, 255, 255, 0.08);
}
}
.auth-card {
padding: 1.25rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background-color: #ffffff;
}
:host-context(.dark) .auth-card {
background-color: #111827;
border-color: #1f2937;
}
.status-line {
margin: 0 0 0.75rem;
font-size: 0.9375rem;
}
.status-line--ok {
color: #15803d;
}
.status-line--error {
color: #b91c1c;
}
:host-context(.dark) .status-line--ok {
color: #4ade80;
}
:host-context(.dark) .status-line--error {
color: #fca5a5;
}
.session-detail {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 0.25rem 1rem;
margin: 0;
font-size: 0.875rem;
dt {
color: #6b7280;
font-weight: 500;
}
dd {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-all;
}
}
:host-context(.dark) .session-detail dt {
color: #9ca3af;
}
.btn {
display: inline-flex;
align-items: center;
height: 2.25rem;
padding: 0 0.875rem;
border: 1px solid transparent;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
}
}
.btn--primary {
background-color: var(--color-brand-primary-600, #2563eb);
color: #ffffff;
&:hover {
background-color: var(--color-brand-primary-700, #1d4ed8);
}
}
.btn--secondary {
background-color: transparent;
border-color: #d1d5db;
color: inherit;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
}
.roadmap {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
:host-context(.dark) .roadmap {
border-top-color: #1f2937;
}
.roadmap-heading {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.75rem;
}
.roadmap-list {
margin: 0;
padding-left: 1.25rem;
font-size: 0.9375rem;
line-height: 1.7;
li {
margin-bottom: 0.25rem;
}
}
@@ -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 {
AUTH_BFF_BASE_URL,
AUTH_NAVIGATOR,
AUTH_PATH_PREFIX,
type CurrentUser,
} from 'feature-auth';
import { Home } from './home';
const BFF_BASE = 'http://bff.test/api';
const ADMIN_ME_URL = `${BFF_BASE}/admin/auth/me`;
const USER: CurrentUser = {
oid: 'admin-oid',
tid: 'tenant-1',
username: 'admin@apf.example',
displayName: 'Admin Smith',
};
async function setup(opts: { onBootstrap: 'authenticated' | 'anonymous' }) {
const navigate = vi.fn();
TestBed.configureTestingModule({
imports: [Home],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
{ provide: AUTH_NAVIGATOR, useValue: navigate },
],
});
const fixture = TestBed.createComponent(Home);
const http = TestBed.inject(HttpTestingController);
const req = http.expectOne(ADMIN_ME_URL);
if (opts.onBootstrap === 'authenticated') {
req.flush(USER);
} else {
req.flush({}, { status: 401, statusText: 'Unauthorized' });
}
await Promise.resolve();
fixture.detectChanges();
await fixture.whenStable();
return { fixture, http, navigate };
}
describe('Home', () => {
it('renders the "no session" state with a Sign in button when anonymous', async () => {
const { fixture } = await setup({ onBootstrap: 'anonymous' });
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.status-line')?.textContent).toContain('No admin session');
expect(root.querySelector('.btn--primary')?.textContent?.trim()).toBe('Sign in via Entra');
});
it('renders the signed-in payload (display name + tenant + oid) when authenticated', async () => {
const { fixture } = await setup({ onBootstrap: 'authenticated' });
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.status-line--ok')?.textContent).toContain('Admin Smith');
const dds = Array.from(root.querySelectorAll<HTMLElement>('.session-detail dd')).map((el) =>
el.textContent?.trim(),
);
expect(dds).toContain('admin@apf.example');
expect(dds).toContain('tenant-1');
expect(dds).toContain('admin-oid');
});
it('navigates to the admin login URL on Sign in click', async () => {
const { fixture, navigate } = await setup({ onBootstrap: 'anonymous' });
(fixture.nativeElement as HTMLElement)
.querySelector<HTMLButtonElement>('.btn--primary')
?.click();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/login`);
});
it('lists the roadmap items (audit log, CMS, menu, user list)', async () => {
const { fixture } = await setup({ onBootstrap: 'anonymous' });
const items = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('.roadmap-list li'),
).map((li) => li.textContent ?? '');
expect(items.some((t) => t.includes('Audit log viewer'))).toBe(true);
expect(items.some((t) => t.includes('CMS pages'))).toBe(true);
expect(items.some((t) => t.includes('Menu management'))).toBe(true);
expect(items.some((t) => t.includes('User list'))).toBe(true);
});
});
+22 -7
View File
@@ -1,16 +1,31 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { AuthService } from 'feature-auth';
/**
* Smoke-test landing page for portal-admin.
* Admin landing page. v1 renders a self-test panel for the
* admin-portal auth chain: status from `AuthService` (signed-in
* payload + roles, or anonymous), a "Sign in" button when no admin
* session is present, and the next-up roadmap from ADR-0020 §"v1
* scope".
*
* v1 ships a placeholder. The real admin shell (header + sidebar +
* footer with the "Admin" badge, per ADR-0020) lands in a follow-up
* PR together with the first functional module (CMS pages, menu
* management, user list, or audit log viewer).
* Functional modules (CMS, menu management, user list, audit log
* viewer) replace this page progressively. The audit-log viewer is
* next per the chantier sequence.
*/
@Component({
selector: 'app-home',
templateUrl: './home.html',
styleUrl: './home.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Home {}
export class Home {
private readonly auth = inject(AuthService);
protected readonly state = this.auth.state;
protected readonly currentUser = this.auth.currentUser;
protected readonly isLoading = this.auth.isLoading;
protected signIn(): void {
this.auth.login();
}
}
@@ -0,0 +1,37 @@
/**
* Per-environment configuration for `portal-admin`, per ADR-0018.
*
* Holds the dev defaults; per-environment siblings
* (`environment.staging.ts`, `environment.prod.ts`) land alongside
* once those targets exist, and `project.json` declares a
* `fileReplacements` entry to swap at build time.
*
* Shape mirrors `apps/portal-shell/src/environments/environment.ts`
* intentionally: any change here that affects the consumer contract
* with `feature-auth` must be made in both files (same env vars on
* the lib side, same BFF base URL pattern).
*/
export const environment = {
/**
* Origin + prefix of the BFF HTTP API. Same value as portal-shell
* — both SPAs talk to the same BFF (per ADR-0020 §"Where does
* the admin app live"). The admin-specific routing happens via
* the `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in
* `app.config.ts`, not by talking to a different host.
*/
bffApiBaseUrl: 'http://localhost:3000/api',
/**
* Name of the BFF's CSRF cookie. v1 reuses `portal_csrf`
* (shared between surfaces; see PR #129's notes on
* `__Host-portal_csrf` / `portal_csrf`). A future hardening
* could split this into `portal_admin_csrf` when both portals
* are open simultaneously becomes a regular pattern.
*/
bffCsrfCookieName: 'portal_csrf',
/**
* OTLP/HTTP traces endpoint — same OTel collector as portal-shell.
*/
otlpEndpoint: 'http://localhost:4318/v1/traces',
};
+9 -1
View File
@@ -5,5 +5,13 @@
"types": ["@angular/localize"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
"references": [
{
"path": "../../libs/shared/ui"
},
{
"path": "../../libs/feature/auth"
}
]
}
+6 -1
View File
@@ -1,4 +1,9 @@
export { AUTH_BFF_BASE_URL, AUTH_CSRF_COOKIE_NAME, AUTH_NAVIGATOR } from './lib/auth.config';
export {
AUTH_BFF_BASE_URL,
AUTH_CSRF_COOKIE_NAME,
AUTH_NAVIGATOR,
AUTH_PATH_PREFIX,
} from './lib/auth.config';
export { authGuard } from './lib/auth.guard';
export { AuthService } from './lib/auth.service';
export type { AuthState, CurrentUser } from './lib/auth.types';
+17
View File
@@ -35,3 +35,20 @@ export const AUTH_NAVIGATOR = new InjectionToken<(url: string) => void>('AUTH_NA
* (`portal_csrf` dev / `__Host-portal_csrf` prod) the BFF picks.
*/
export const AUTH_CSRF_COOKIE_NAME = new InjectionToken<string>('AUTH_CSRF_COOKIE_NAME');
/**
* Path prefix the BFF mounts the OIDC routes under. Joined to
* `AUTH_BFF_BASE_URL` to build the full URLs for `/me`, `/login`,
* `/logout`. `portal-shell` provides `/auth` (per ADR-0009);
* `portal-admin` provides `/admin/auth` (per ADR-0020 §"Sessions —
* distinct from `portal-shell`"). The lib stays surface-agnostic;
* the host picks which BFF auth surface its session belongs to.
*
* Default is `/auth` so portal-shell-shaped consumers can omit the
* provider and still work — matches the original (pre-admin) lib
* shape. Hosts on the admin surface MUST override.
*/
export const AUTH_PATH_PREFIX = new InjectionToken<string>('AUTH_PATH_PREFIX', {
providedIn: 'root',
factory: () => '/auth',
});
+25 -2
View File
@@ -1,7 +1,7 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './auth.config';
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR, AUTH_PATH_PREFIX } from './auth.config';
import { AuthService } from './auth.service';
import type { CurrentUser } from './auth.types';
@@ -98,13 +98,36 @@ describe('AuthService', () => {
});
describe('URL accessors', () => {
it('derives me / login / logout URLs from the injected base', () => {
it('derives me / login / logout URLs from the injected base + default /auth prefix', () => {
const { service, http } = setup();
expect(service.meUrl).toBe(`${BFF_BASE}/auth/me`);
expect(service.loginUrl).toBe(`${BFF_BASE}/auth/login`);
expect(service.logoutUrl).toBe(`${BFF_BASE}/auth/logout`);
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
it('honours AUTH_PATH_PREFIX when the host provides a non-default value (admin surface)', () => {
const navigate = vi.fn();
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
{ provide: AUTH_NAVIGATOR, useValue: navigate },
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
AuthService,
],
});
const service = TestBed.inject(AuthService);
const http = TestBed.inject(HttpTestingController);
expect(service.meUrl).toBe(`${BFF_BASE}/admin/auth/me`);
expect(service.loginUrl).toBe(`${BFF_BASE}/admin/auth/login`);
expect(service.logoutUrl).toBe(`${BFF_BASE}/admin/auth/logout`);
// The auto-refresh on first inject fires against the override path.
http
.expectOne(`${BFF_BASE}/admin/auth/me`)
.flush({}, { status: 401, statusText: 'Unauthorized' });
});
});
describe('login() / logout()', () => {
+5 -4
View File
@@ -1,7 +1,7 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './auth.config';
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR, AUTH_PATH_PREFIX } from './auth.config';
import type { AuthState, CurrentUser } from './auth.types';
/**
@@ -23,6 +23,7 @@ import type { AuthState, CurrentUser } from './auth.types';
export class AuthService {
private readonly http = inject(HttpClient);
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
private readonly pathPrefix = inject(AUTH_PATH_PREFIX);
private readonly navigate = inject(AUTH_NAVIGATOR);
private readonly _state = signal<AuthState>({ kind: 'loading' });
@@ -95,15 +96,15 @@ export class AuthService {
}
get meUrl(): string {
return `${this.bffBaseUrl}/auth/me`;
return `${this.bffBaseUrl}${this.pathPrefix}/me`;
}
get loginUrl(): string {
return `${this.bffBaseUrl}/auth/login`;
return `${this.bffBaseUrl}${this.pathPrefix}/login`;
}
get logoutUrl(): string {
return `${this.bffBaseUrl}/auth/logout`;
return `${this.bffBaseUrl}${this.pathPrefix}/logout`;
}
}