feat(portal-admin): spa auth wiring + admin shell skeleton (#134)
## Summary Phase-3a step per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"Confirmation" item 4 (entry route + admin shell). Wires the existing [`feature-auth`](libs/feature/auth) library against the distinct admin OIDC routes (`/api/admin/auth/*`) the BFF exposes since PR #129, ships a lean header/sidebar/footer chrome with an "Admin" badge so an internal user can never mistake the surface for portal-shell, and gives the landing page a self-test panel that confirms the auth chain end-to-end as soon as an `admin` Entra role gets assigned. ## What lands ### Lib change — `AUTH_PATH_PREFIX` injection token [`libs/feature/auth`](libs/feature/auth/src/lib/auth.config.ts) gains one injection token. AuthService composes URLs as `${bffBaseUrl}${pathPrefix}/{me,login,logout}` instead of hard-coding `/auth/...`. Default factory returns `/auth`, so portal-shell-shaped consumers keep working without an explicit provider — no churn on existing call sites. Admin hosts override with `/admin/auth`. The interceptors (`bffCredentialsInterceptor`, `csrfInterceptor`, `bffUnauthorizedInterceptor`) are **unchanged** — they only care about the BFF base URL, not the path prefix. ### portal-admin wiring - [environment.ts](apps/portal-admin/src/environments/environment.ts) — same shape as portal-shell, same BFF base URL (both SPAs talk to one BFF per ADR-0020 §"Where does the admin app live"). Same CSRF cookie name in v1. - [app.config.ts](apps/portal-admin/src/app/app.config.ts) — `HttpClient` with the standard interceptor chain (credentials → csrf → unauthorized), `AUTH_BFF_BASE_URL` from env, `AUTH_PATH_PREFIX = '/admin/auth'`, `AUTH_CSRF_COOKIE_NAME` from env. ### Admin shell - **[AdminHeader](apps/portal-admin/src/app/components/header/header.ts)** — APF wordmark + persistent "Admin" badge + auth widget. No global search / notifications / help cluster: admins land on tabular workloads, not a discovery dashboard (ADR-0020 §"UX style is data-dense"). - **[AdminSidebar](apps/portal-admin/src/app/components/sidebar/sidebar.ts)** — static menu listing the four ADR-0020 v1 modules. Audit log is a live router link (target of the next PR); the others are `aria-disabled` placeholders with a "Soon" badge so the navigation shape is visible even before they ship. - **[AdminFooter](apps/portal-admin/src/app/components/footer/footer.ts)** — copyright + persistent "Admin surface" tag. Stays in view for long workloads where the header has scrolled off. ### Home — auth self-test panel [apps/portal-admin/src/app/pages/home/](apps/portal-admin/src/app/pages/home/): - Signed-in: shows `displayName`, `username`, `tenant`, `oid` in a monospaced detail list. - Anonymous: "No admin session detected" + a "Sign in via Entra" button that delegates to `AuthService.login()` → 302 through `/api/admin/auth/login`. - Error: "Could not reach the BFF" + retry button. - Roadmap list quoting ADR-0020's v1 catalogue. ## Known limitations (v1, documented) - **CSRF cookie shared between surfaces.** Both portals issue `portal_csrf` on session creation. A user with both portals open will see overwrites on the second sign-in; mutating actions on the first surface will then 403 until refresh. Splitting to `portal_admin_csrf` is a follow-up if the pattern becomes common. - **Shell chrome strings are plain English.** The admin app's `$localize` plumbing is wired (matches portal-shell), but adding markers to the shell here would require regenerating `messages.fr.xlf` and `i18nMissingTranslation=error` fails the prod build on every gap. Full admin i18n is its own follow-up. - **`LayoutStateService` not yet consumed.** No collapse toggle in v1. Theme preference still threads through because both apps read the same `localStorage` key — toggle in portal-shell, see it honoured in portal-admin. - **`ci:perf` only runs against portal-shell.** Admin perf budgets are enforced at build time by `apps/portal-admin/project.json` (`maximumError == maximumWarning` per ADR-0020's relaxed thresholds: 500 KB initial JS / 1.5 MB initial total). Adding admin to `pnpm ci:perf`'s gzip + Lighthouse chain is a follow-up. ## Test plan - [x] `pnpm nx test feature-auth` — **29 specs pass** (was 28; +1 for the `AUTH_PATH_PREFIX` override). - [x] `pnpm nx test portal-admin` — **15 specs pass** (was 1; +14: AdminHeader 5, AdminSidebar 3, AdminFooter 2, Home 4, App 1 expanded). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Gzip-budget script run manually against `dist/apps/portal-admin/browser`: 86.65 KB initial / 300 KB budget, 4.46 KB CSS / 150 KB budget, 2.15 KB largest lazy / 100 KB per-chunk budget. Both `en` + `fr` bundles checked thanks to PR #133's multi-locale detection. - [ ] e2e — pending an Entra `admin` role assignment. Once available: `pnpm nx serve portal-admin`, visit `http://localhost:4300`, see "No admin session detected", click "Sign in via Entra", complete the round-trip, land back on `/` with the signed-in payload + `portal_admin_session` cookie set. ## Notes for the reviewer - `AdminHeader` is intentionally narrower than `Header` from portal-shell — no shared base class. The two are likely to evolve in different directions (admin-specific banner with system-status badges, audit-trail link, etc.) and a premature abstraction would be expensive to undo. - `AdminSidebar`'s "Soon" badge is a deliberate signal — without it, an admin who clicked a placeholder link and got a route-not-found would assume the app is broken. The badge sets expectations. - All shell components use `:host-context(.dark)` for dark-mode SCSS instead of `:host(.dark)` — same pattern as portal-shell, since the `.dark` class lives on `<html>` outside the component's view encapsulation. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #134
This commit was merged in pull request #134.
This commit is contained in:
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
Reference in New Issue
Block a user