+
diff --git a/apps/portal-admin/src/app/components/header/header.scss b/apps/portal-admin/src/app/components/header/header.scss
new file mode 100644
index 0000000..fb5c9ec
--- /dev/null
+++ b/apps/portal-admin/src/app/components/header/header.scss
@@ -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);
+ }
+}
diff --git a/apps/portal-admin/src/app/components/header/header.spec.ts b/apps/portal-admin/src/app/components/header/header.spec.ts
new file mode 100644
index 0000000..d18f83f
--- /dev/null
+++ b/apps/portal-admin/src/app/components/header/header.spec.ts
@@ -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('.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('.btn--secondary')
+ ?.click();
+ expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/logout`);
+ });
+});
diff --git a/apps/portal-admin/src/app/components/header/header.ts b/apps/portal-admin/src/app/components/header/header.ts
new file mode 100644
index 0000000..aa6f4a1
--- /dev/null
+++ b/apps/portal-admin/src/app/components/header/header.ts
@@ -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();
+}
diff --git a/apps/portal-admin/src/app/components/sidebar/sidebar.html b/apps/portal-admin/src/app/components/sidebar/sidebar.html
new file mode 100644
index 0000000..003c266
--- /dev/null
+++ b/apps/portal-admin/src/app/components/sidebar/sidebar.html
@@ -0,0 +1,25 @@
+
diff --git a/apps/portal-admin/src/app/components/sidebar/sidebar.scss b/apps/portal-admin/src/app/components/sidebar/sidebar.scss
new file mode 100644
index 0000000..b56b153
--- /dev/null
+++ b/apps/portal-admin/src/app/components/sidebar/sidebar.scss
@@ -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);
+}
diff --git a/apps/portal-admin/src/app/components/sidebar/sidebar.spec.ts b/apps/portal-admin/src/app/components/sidebar/sidebar.spec.ts
new file mode 100644
index 0000000..c5ba493
--- /dev/null
+++ b/apps/portal-admin/src/app/components/sidebar/sidebar.spec.ts
@@ -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('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');
+ }
+ });
+});
diff --git a/apps/portal-admin/src/app/components/sidebar/sidebar.ts b/apps/portal-admin/src/app/components/sidebar/sidebar.ts
new file mode 100644
index 0000000..4456efb
--- /dev/null
+++ b/apps/portal-admin/src/app/components/sidebar/sidebar.ts
@@ -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;
+}
diff --git a/apps/portal-admin/src/app/pages/home/home.html b/apps/portal-admin/src/app/pages/home/home.html
index b446b12..5fa1ec0 100644
--- a/apps/portal-admin/src/app/pages/home/home.html
+++ b/apps/portal-admin/src/app/pages/home/home.html
@@ -1,24 +1,41 @@
-
-
- APF Portal Admin
-
-
- 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.
+
+
APF Portal Admin
+
+ Administrative back-office for the APF Portal. Sign in with an Entra account that carries the
+ admin app role to reach the functional modules.