Compare commits

..

1 Commits

Author SHA1 Message Date
APF Portal Bot d25051e418 chore(deps): update jest monorepo to v30.4.0
CI / commits (pull_request) Has been skipped
CI / perf (pull_request) Has been skipped
CI / scan (pull_request) Successful in 2m49s
CI / check (pull_request) Successful in 3m12s
CI / a11y (pull_request) Successful in 56s
2026-05-07 22:43:15 +00:00
506 changed files with 4477 additions and 57049 deletions
-14
View File
@@ -1,14 +0,0 @@
# Deque axe Linter config — silences false positives the static
# HTML scanner emits against Angular 17+ control-flow blocks
# (`@for`, `@if`, `@switch`) used inline in templates.
#
# The `list` rule (WCAG 1.3.1) flags `@for (item of …) { <li>… }`
# constructs as if the `@for` text were a non-<li> child of <ul>/<ol>.
# At build time, the Angular compiler erases those tokens — the
# rendered DOM only contains <li> children. The axe-playwright suite
# wired into CI per ADR-0016 runs against the rendered DOM, so the
# real accessibility contract is enforced there; this file only
# tames the editor-side noise.
global-disable:
- list
-65
View File
@@ -1,65 +0,0 @@
// `apf-portal` devcontainer — VSCode Dev Containers spec.
//
// Pinned Node + pnpm via corepack via the official typescript-node image
// (Bookworm base). The container has access to the host docker daemon
// through the docker socket (`docker-outside-of-docker` feature), so
// `infra/local/dev.sh up` on the HOST creates the postgres/redis/otel
// containers and this container connects to them through the shared
// `apf-portal-dev` network.
//
// Workflow:
// 1. On the host: ./infra/local/dev.sh up (creates the network)
// 2. In VSCode: F1 → "Dev Containers: Reopen in Container"
// 3. Inside the container: pnpm exec nx serve portal-bff (connects to postgres:5432)
{
"name": "apf-portal",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-24-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
"ghcr.io/devcontainers/features/common-utils:2": {
"installZsh": false,
"configureZshAsDefaultShell": false
}
},
// `initializeCommand` runs on the HOST before the container starts.
// Fail fast if the infra network isn't up — otherwise the BFF would
// get connection-refused on every Postgres query inside the container.
"initializeCommand": "docker network inspect apf-portal-dev > /dev/null 2>&1 || { echo '❌ Bring up infra first: ./infra/local/dev.sh up' >&2 ; exit 1 ; }",
// Attach to the same Compose network as postgres/redis/otel so DNS
// resolution (`postgres:5432`) works from inside the container.
"runArgs": ["--network=apf-portal-dev"],
"forwardPorts": [4200, 4300, 3000, 8081, 16686, 4317, 4318],
"portsAttributes": {
"4200": { "label": "portal-shell", "onAutoForward": "notify" },
"4300": { "label": "portal-admin", "onAutoForward": "notify" },
"3000": { "label": "portal-bff", "onAutoForward": "notify" },
"8081": { "label": "pgweb", "onAutoForward": "silent" },
"16686": { "label": "jaeger UI", "onAutoForward": "silent" },
"4317": { "label": "otel gRPC", "onAutoForward": "silent" },
"4318": { "label": "otel HTTP", "onAutoForward": "silent" }
},
"postCreateCommand": "bash .devcontainer/post-create.sh",
"remoteUser": "node",
"containerEnv": {
// Nx + Angular CLI memory ceiling — 4 GB. Bumping past the default
// 2 GB avoids OOM in `nx build` on large monorepos.
"NODE_OPTIONS": "--max-old-space-size=4096"
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"Angular.ng-template",
"Prisma.prisma",
"ms-azuretools.vscode-docker",
"EditorConfig.EditorConfig"
],
"settings": {
"terminal.integrated.defaultProfile.linux": "bash",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
}
}
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
# Runs once, on the container's first start. Idempotent.
set -euo pipefail
echo "▶ Enabling corepack (pnpm shim from package.json#packageManager)…"
corepack enable
echo "▶ pnpm install --frozen-lockfile…"
pnpm install --frozen-lockfile
echo "✓ Devcontainer ready."
echo " Suggested next: pnpm exec nx run-many -t lint test --parallel=3"
+22 -18
View File
@@ -2,6 +2,15 @@
# Thin YAML — orchestration lives in package.json scripts (ci:check, # Thin YAML — orchestration lives in package.json scripts (ci:check,
# ci:audit, ci:commits, ci:perf) and Nx targets. Any change to gate # ci:audit, ci:commits, ci:perf) and Nx targets. Any change to gate
# behaviour belongs in those scripts, not in this file. # behaviour belongs in those scripts, not in this file.
#
# `cache: 'pnpm'` is intentionally NOT enabled on actions/setup-node
# below: act_runner's built-in GitHub-Actions-cache server is bound on
# the runner container and is unreachable from job containers (which
# run on Docker's default network, not the runners' compose network).
# Each request burns a ~2 min ETIMEDOUT on restore + another on save,
# for zero hit rate. Once the runner network/cache config is fixed
# (tracked in infra/README.md "Cache server"), re-add `cache: 'pnpm'`
# here.
name: CI name: CI
@@ -41,18 +50,8 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm ci:check - run: pnpm ci:check
# Catalogue-vs-code drift gate per ADR-0025 §"Confirmation".
# Cheap (parses ~workspace .ts files via the TypeScript
# compiler API, ~1s on a warm cache) so it rides the same
# `check` job rather than spinning up a new one. The
# script's own unit tests run first — if they fail the gate
# itself is broken and the actual catalogue check would be
# noise.
- run: pnpm ci:catalogue-drift:test
- run: pnpm ci:catalogue-drift
scan: scan:
runs-on: [self-hosted, on-prem] runs-on: [self-hosted, on-prem]
@@ -72,13 +71,14 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm'
# Dependency vulnerability scan. Trivy is a Go binary, not an npm # Dependency vulnerability scan. Trivy is a Go binary, not an npm
# package, so it cannot live in package.json scripts as cleanly # package, so it cannot live in package.json scripts as cleanly
# as audit/lint do. # as audit/lint do.
# #
# We deliberately avoid `aquasecurity/trivy-action`. On cache # We deliberately avoid `aquasecurity/trivy-action`. On cache
# miss the action falls back to `git clone github.com/aquasecurity/ # miss (our act_runner cache server is unreachable from job
# containers — see infra/README.md "Cache server (deferred)"),
# the action falls back to `git clone github.com/aquasecurity/
# trivy` to fetch its install script, using `actions/checkout` # trivy` to fetch its install script, using `actions/checkout`
# which defaults `with.token` to `${{ github.token }}` (Gitea's # which defaults `with.token` to `${{ github.token }}` (Gitea's
# auto-token, useless for github.com). The clone hits the # auto-token, useless for github.com). The clone hits the
@@ -93,7 +93,12 @@ jobs:
# unmetered, but auth is free insurance). # unmetered, but auth is free insurance).
- name: Install Trivy - name: Install Trivy
env: env:
# renovate: datasource=github-releases depName=aquasecurity/trivy # Bump deliberately when a security advisory or new feature
# warrants it. Renovate cannot manage this pin out of the
# box (it is not a package-manager-tracked dep); a custom
# regex manager could be added later if the cadence justifies
# it. For now, manual updates from
# https://github.com/aquasecurity/trivy/releases.
TRIVY_VERSION: '0.70.0' TRIVY_VERSION: '0.70.0'
GITHUB_TOKEN: ${{ secrets.GITHUBCOM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUBCOM_TOKEN }}
run: | run: |
@@ -128,8 +133,10 @@ jobs:
# wrapper and gives us version pinning for free. # wrapper and gives us version pinning for free.
- name: Install gitleaks - name: Install gitleaks
env: env:
# renovate: datasource=github-releases depName=gitleaks/gitleaks # Bump deliberately. Same caveat as TRIVY_VERSION above —
GITLEAKS_VERSION: '8.30.1' # not Renovate-tracked out of the box. Releases:
# https://github.com/gitleaks/gitleaks/releases.
GITLEAKS_VERSION: '8.21.0'
GITHUB_TOKEN: ${{ secrets.GITHUBCOM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUBCOM_TOKEN }}
run: | run: |
curl -sfL \ curl -sfL \
@@ -171,7 +178,6 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: COMMIT_LINT_FROM=origin/main pnpm ci:commits - run: COMMIT_LINT_FROM=origin/main pnpm ci:commits
@@ -196,7 +202,6 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm ci:perf - run: pnpm ci:perf
- uses: actions/upload-artifact@v7 - uses: actions/upload-artifact@v7
@@ -214,7 +219,6 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
# Placeholder until the e2e a11y suite (axe-core via Playwright, # Placeholder until the e2e a11y suite (axe-core via Playwright,
# per ADR-0016) is wired with the first real screens. The job # per ADR-0016) is wired with the first real screens. The job
-73
View File
@@ -1,73 +0,0 @@
# Per ADR-0022 (Documentation site — VitePress).
# Builds the static docs site from `docs/`. On `pull_request` the
# build is a syntax / dead-link gate; on `push` to `main` the same
# build runs and the bundle is uploaded as an artifact so the
# operator can drop it on the static host until the future
# infrastructure ADR locks the rsync target.
#
# Path filter scopes the workflow to changes that can actually
# affect the rendered site: documentation content, the VitePress
# configuration itself, and dependency manifests (Vite/VitePress
# upgrades).
name: Docs site
on:
pull_request:
branches: [main]
paths:
- 'docs/**'
- 'package.json'
- 'pnpm-lock.yaml'
- '.gitea/workflows/docs-site.yml'
push:
branches: [main]
paths:
- 'docs/**'
- 'package.json'
- 'pnpm-lock.yaml'
- '.gitea/workflows/docs-site.yml'
jobs:
build:
runs-on: [self-hosted, on-prem]
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# `pnpm docs:build` runs `vitepress build docs` per the script
# added in this chantier. Fails the workflow on:
# - markdown / Vue-template parse errors,
# - genuine dead in-site links (the config's
# `ignoreDeadLinks` regex already covers the intentional
# cross-repo + localhost references that must not break
# the build).
- name: Build docs site
run: pnpm docs:build
# Sanity-check: the build must produce HTML where the Mermaid
# plugin has injected its diagram markers. Guards against a
# silent regression on the plugin upgrade path — `pnpm install`
# might happily resolve a new major that no longer hooks into
# markdown-it the same way, leaving fenced ```mermaid blocks
# as raw text. Per ADR-0022 §"Confirmation".
- name: Assert Mermaid renders into the build output
run: |
if ! grep -RIlq 'class="mermaid"\|<svg' docs/.vitepress/dist/decisions/0009-auth-flow-oidc-pkce-msal-node.html; then
echo "ADR-0009's Mermaid sequence diagram did not render — Mermaid plugin regression?" >&2
exit 1
fi
# On push only — upload the static bundle so the operator can
# rsync it to the host until the future infra ADR formalises
# the deployment target. The artifact lives 30 days in Gitea's
# storage by default.
- name: Upload docs bundle
if: github.event_name == 'push'
uses: actions/upload-artifact@v3
with:
name: docs-site
path: docs/.vitepress/dist/
retention-days: 30
+1 -5
View File
@@ -33,14 +33,12 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm'
# Full-tree Trivy (no skip-dirs, no severity filter — the per-PR # Full-tree Trivy (no skip-dirs, no severity filter — the per-PR
# gate filters by severity for speed; this run wants the full # gate filters by severity for speed; this run wants the full
# surface for the security feed). Manual install + curl, same # surface for the security feed). Manual install + curl, same
# pattern as ci.yml — see the rationale there. # pattern as ci.yml — see the rationale there.
- name: Install Trivy - name: Install Trivy
env: env:
# renovate: datasource=github-releases depName=aquasecurity/trivy
TRIVY_VERSION: '0.70.0' TRIVY_VERSION: '0.70.0'
GITHUB_TOKEN: ${{ secrets.GITHUBCOM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUBCOM_TOKEN }}
run: | run: |
@@ -61,8 +59,7 @@ jobs:
# don't leak it via CI logs themselves. # don't leak it via CI logs themselves.
- name: Install gitleaks - name: Install gitleaks
env: env:
# renovate: datasource=github-releases depName=gitleaks/gitleaks GITLEAKS_VERSION: '8.21.0'
GITLEAKS_VERSION: '8.30.1'
GITHUB_TOKEN: ${{ secrets.GITHUBCOM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUBCOM_TOKEN }}
run: | run: |
curl -sfL \ curl -sfL \
@@ -93,7 +90,6 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm exec lhci collect --url=${{ vars.LHCI_PROD_URL }} --numberOfRuns=3 - run: pnpm exec lhci collect --url=${{ vars.LHCI_PROD_URL }} --numberOfRuns=3
- run: pnpm exec lhci assert --config=./lighthouserc.js - run: pnpm exec lhci assert --config=./lighthouserc.js
-13
View File
@@ -10,10 +10,6 @@ tmp/
.nx/cache/ .nx/cache/
.nx/workspace-data/ .nx/workspace-data/
# VitePress (docs site per ADR-0022)
docs/.vitepress/cache/
docs/.vitepress/dist/
# Test artifacts # Test artifacts
coverage/ coverage/
.test-output/ .test-output/
@@ -31,15 +27,6 @@ pnpm-debug.log*
*.pem *.pem
*.key *.key
# Tenant-private Entra group GUIDs per ADR-0025 (commit only the .example.json)
infra/*-tenant.entra.json
!infra/*-tenant.entra.example.json
# Tenant-private Entra per-persona oids consumed by prisma/seed.ts
# (ADR-0026 §"Seed personas"). Same pattern — commit only the example.
infra/*-tenant.personas.json
!infra/*-tenant.personas.example.json
# OS / editor scrap # OS / editor scrap
.DS_Store .DS_Store
Thumbs.db Thumbs.db
-159
View File
@@ -1,159 +0,0 @@
# Per ADR-0028 (CI/CD migration from Gitea Actions to GitLab CE).
# Thin YAML — orchestration lives in package.json scripts (ci:check,
# ci:catalogue-drift, ci:audit, ci:commits, ci:perf, ci:gzip-budgets)
# and Nx targets. Any change to gate behaviour belongs in those
# scripts, not in this file — see ADR-0015 §"Thin pipeline YAML…"
# (principle preserved by ADR-0028 at the migration boundary).
#
# Phase 2 of ADR-0028: ships alongside .gitea/workflows/ci.yml. Both
# pipelines must remain in parity until cutover (Phase 3), at which
# point the Gitea workflow gets deleted.
include:
# GitLab built-in scanners — replace the manual Trivy + gitleaks
# install in .gitea/workflows/ci.yml. Both add their own jobs to
# the pipeline; their default stage is `test`, which is mapped in
# `stages:` below.
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
stages:
- check
- test
- commits
- perf
- a11y
workflow:
rules:
# MR pipelines — covers GitLab MR review flow once it lands.
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# Direct pushes — covers post-merge runs on main today, and Phase 2
# parity testing on feature branches mirrored from Gitea. To be
# tightened to `$CI_COMMIT_BRANCH == "main"` at the Phase 3 cutover
# once GitLab is the source of truth.
- if: $CI_PIPELINE_SOURCE == "push"
# Shared shape for jobs that need Node + pnpm. Hidden (`.` prefix) so
# GitLab does not try to execute it. Inheriting jobs `extends: .node-job`.
.node-job:
image: node:24-bookworm
cache:
key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store/
before_script:
- corepack enable
- corepack prepare pnpm@10.33.4 --activate
- pnpm config set store-dir "$CI_PROJECT_DIR/.pnpm-store"
check:
extends: .node-job
stage: check
# `nx affected` needs a base SHA to diff against. Mirrors the
# equivalent step in .gitea/workflows/ci.yml — same logic, GitLab
# variable names. Replaces nrwl/nx-set-shas (GitHub-only).
script:
- |
if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then
git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --depth=100
export NX_BASE=$(git merge-base HEAD "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME")
else
export NX_BASE="HEAD~1"
fi
export NX_HEAD="HEAD"
- pnpm install --frozen-lockfile
- pnpm ci:check
# Catalogue-vs-code drift gate per ADR-0025 §"Confirmation". The
# script's own unit tests run first — if they fail the gate itself
# is broken and the actual catalogue check would be noise.
- pnpm ci:catalogue-drift:test
- pnpm ci:catalogue-drift
audit:
extends: .node-job
stage: test
# npm-advisory check against pnpm-lock.yaml. Trivy's dependency-vuln
# role from the Gitea workflow is now covered by the SAST include
# (which includes Dependency Scanning analyzers); `pnpm audit` stays
# in-pipeline as defense in depth against the npm advisory DB
# specifically.
script:
- pnpm install --frozen-lockfile
- pnpm ci:audit
commits:
extends: .node-job
stage: commits
# MRs opened by Renovate (apf-portal-bot) carry commit messages from
# a vetted Conventional-Commits template — running commitlint on
# them is tautological. Per ADR-0017 amendment.
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $GITLAB_USER_LOGIN != "apf-portal-bot"
variables:
# Default `GIT_DEPTH: 20` is too shallow to diff against
# origin/main on long-running branches. Full clone so commitlint
# can walk all the way back.
GIT_DEPTH: 0
script:
- git fetch origin main
- pnpm install --frozen-lockfile
- COMMIT_LINT_FROM=origin/main pnpm ci:commits
perf:
extends: .node-job
stage: perf
# Lighthouse CI drives a real Chrome via chrome-launcher, which
# probes the PATH / CHROME_PATH for a standard Chrome/Chromium
# binary. The Playwright image bundles Chromium under /ms-playwright
# with a non-standard name chrome-launcher cannot discover, so we
# stay on the base Node image and apt-install Debian's `chromium`
# (lands at /usr/bin/chromium). The future ADR-0016 axe-core e2e job
# — which drives Playwright directly — will use the Playwright image
# instead: the two tools want different things, so they get
# different images rather than one image that serves both poorly.
image: node:24-bookworm
variables:
CHROME_PATH: /usr/bin/chromium
before_script:
- !reference [.node-job, before_script]
- apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium
# Skip on Renovate MRs (same rationale as commits + the perf signal
# on a dep bump is essentially zero). Push events on `main` still
# run perf — we catch regressions immediately post-merge, not
# pre-merge. Per ADR-0017 amendment.
rules:
- if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $GITLAB_USER_LOGIN != "apf-portal-bot"
script:
- pnpm install --frozen-lockfile
- pnpm ci:perf
artifacts:
when: always
paths:
- .lighthouseci/
expire_in: 30 days
a11y:
extends: .node-job
stage: a11y
# Placeholder until the e2e a11y suite (axe-core via Playwright, per
# ADR-0016) is wired with the first real screens. The job exists so
# branch protection can require it from day one — currently no-ops
# with a clear message.
script:
- echo "a11y gate placeholder - axe-core via Playwright wires up with the first real screens (ADR-0016)."
# Place SAST + Secret Detection in the `test` stage to mirror the
# Gitea workflow's grouping (where Trivy + gitleaks + audit all
# lived in the `scan` job). Override does not change behaviour —
# only stage placement. The included templates set sensible defaults
# (run on default branch + MRs, full repo on default branch / diff
# on MRs); revisited in Phase 3 if pipeline duration becomes a pain.
sast:
stage: test
secret_detection:
stage: test
@@ -1,30 +0,0 @@
<!--
MR title format — becomes the squash-merge subject on main, validated by commitlint.
<type>(<scope>): <short description>
Examples:
feat(portal-shell): add user-preferences panel skeleton
fix(portal-bff): correct env var bracket access
docs(decisions): add ADR-0018 for security baseline
chore(deps): bump @nx/* to 22.7.2
Imperative mood, lowercase, no trailing period, target ≤ 70 chars.
See docs/development.md §7 for the full convention (types, scopes).
-->
## Summary
## Motivation
## Implementation notes
## Verification
- [ ] `pnpm ci:check` green locally
- [ ] `pnpm ci:audit` green (or pre-existing drift acknowledged)
- [ ] Tested manually:
- [ ] Architecture diagram updated (if `docs/architecture.md` was affected)
- [ ] ADR amended or added (if a decision changed)
## Related
-5
View File
@@ -5,8 +5,3 @@
/.nx/workspace-data /.nx/workspace-data
.nx/self-healing .nx/self-healing
.angular .angular
# Generated gRPC TypeScript stubs (output of `pnpm run grpc:codegen`).
# Hand-formatting is not productive — overwritten on next regen — and
# ts-proto's output is internally consistent.
apps/portal-bff/src/grpc/gen/
+6 -35
View File
@@ -32,7 +32,7 @@ These constraints were set by the project lead at kickoff. They apply to every c
The structural, security, observability, and quality choices are recorded as ADRs and summarized below. Any change to these requires updating the corresponding ADR. The structural, security, observability, and quality choices are recorded as ADRs and summarized below. Any change to these requires updating the corresponding ADR.
- **Workspace:** Nx monorepo with the `apps` preset, managed by pnpm — see [ADR-0002](docs/decisions/0002-adopt-nx-monorepo-apps-preset.md). - **Workspace:** Nx monorepo with the `apps` preset, managed by pnpm — see [ADR-0002](docs/decisions/0002-adopt-nx-monorepo-apps-preset.md).
- **Naming:** workspace `apf-portal`; apps `portal-shell` (end-user SPA), `portal-admin` (admin SPA, skeleton in place — see ADR-0020), and `portal-bff` (backend); libs `feature-<name>` and `shared-<scope>` — see [ADR-0003](docs/decisions/0003-workspace-and-app-naming-convention.md). - **Naming:** workspace `apf-portal`; apps `portal-shell` (frontend) and `portal-bff` (backend); libs `feature-<name>` and `shared-<scope>` — see [ADR-0003](docs/decisions/0003-workspace-and-app-naming-convention.md).
- **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](docs/decisions/0004-frontend-stack-angular-csr-zoneless-signals.md). - **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](docs/decisions/0004-frontend-stack-angular-csr-zoneless-signals.md).
- **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](docs/decisions/0005-backend-stack-nestjs.md). - **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](docs/decisions/0005-backend-stack-nestjs.md).
- **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](docs/decisions/0006-persistence-postgresql-prisma.md). - **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](docs/decisions/0006-persistence-postgresql-prisma.md).
@@ -43,48 +43,21 @@ The structural, security, observability, and quality choices are recorded as ADR
- **Observability:** Pino + `nestjs-pino` for structured JSON logs, OpenTelemetry SDK + auto-instrumentations for traces, W3C Trace Context propagation across SPA → BFF → DB → Redis, `nestjs-cls` for request-scoped context (`trace_id`, `session_id`, `user_id_hash`, `audience`), 100 % sampling at the app with tail sampling deferred to the OTel Collector, stdout + OTLP shipping — see [ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md). - **Observability:** Pino + `nestjs-pino` for structured JSON logs, OpenTelemetry SDK + auto-instrumentations for traces, W3C Trace Context propagation across SPA → BFF → DB → Redis, `nestjs-cls` for request-scoped context (`trace_id`, `session_id`, `user_id_hash`, `audience`), 100 % sampling at the app with tail sampling deferred to the OTel Collector, stdout + OTLP shipping — see [ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md).
- **Audit trail:** dedicated `audit.events` schema in the same Postgres instance, append-only by Postgres role grants (`audit_writer` INSERT, `audit_reader` SELECT, `audit_archiver` DELETE older than retention; no `UPDATE`/`TRUNCATE` to anyone); 365-day retention default; cross-referenced with app logs via `trace_id` and `actor_id_hash` (same salt); blocking writes (no audit ⇒ no action) — see [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md). - **Audit trail:** dedicated `audit.events` schema in the same Postgres instance, append-only by Postgres role grants (`audit_writer` INSERT, `audit_reader` SELECT, `audit_archiver` DELETE older than retention; no `UPDATE`/`TRUNCATE` to anyone); 365-day retention default; cross-referenced with app logs via `trace_id` and `actor_id_hash` (same salt); blocking writes (no audit ⇒ no action) — see [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md).
- **Downstream API access:** unified `DownstreamApiClient` (`@nestjs/axios` + `cockatiel`), per-service `DownstreamApiConfig`; default auth strategy is **OBO via MSAL Node** for Entra-protected APIs (downstream-scoped tokens cached in Redis with AES-256-GCM under a dedicated key); fallback strategy is service credential + signed `X-User-Assertion` JWT (BFF JWKS at `/.well-known/jwks.json`); per-call audience pre-check; no `axios`/`fetch` outside `src/downstream/` — see [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md). - **Downstream API access:** unified `DownstreamApiClient` (`@nestjs/axios` + `cockatiel`), per-service `DownstreamApiConfig`; default auth strategy is **OBO via MSAL Node** for Entra-protected APIs (downstream-scoped tokens cached in Redis with AES-256-GCM under a dedicated key); fallback strategy is service credential + signed `X-User-Assertion` JWT (BFF JWKS at `/.well-known/jwks.json`); per-call audience pre-check; no `axios`/`fetch` outside `src/downstream/` — see [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md).
- **CI/CD:** **Gitea Actions today, migrating to GitLab CE on `vm-gitlab` per [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md)** (decision-only ADR accepted; 4-phase rollout in follow-up PRs — the architectural principles below carry over unchanged from [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md), only the host / pipeline file / runner type / scan tooling change). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:catalogue-drift`, `ci:audit`, `ci:commits`, `ci:perf`, `ci:gzip-budgets`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and `perf` (per ADR-0017). Self-hosted runners on-prem (`act_runner` today, GitLab Runner with Docker executor post-migration). Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits required on `main` once the migration cutover lands (ADR-0028's revisit of ADR-0015's "recommended" stance). - **CI/CD:** **Gitea Actions** (level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and future `perf`. Self-hosted `act_runner` on-prem. Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits recommended, revisited at GitLab migration — see [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md).
- **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + TailwindCSS** (spartan-ng _library_ deferred until it reaches 1.0.0; v1 components are written in-house in `libs/shared/ui/` on Angular CDK, applying the spartan-ng _philosophy_ of headless primitives + utility CSS + copy-paste). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md). - **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + TailwindCSS** (spartan-ng _library_ deferred until it reaches 1.0.0; v1 components are written in-house in `libs/shared/ui/` on Angular CDK, applying the spartan-ng _philosophy_ of headless primitives + utility CSS + copy-paste). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md).
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md). - **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
- **Environment configuration:** SPA per-environment values via Angular `environment.ts` + `fileReplacements` at build time (no runtime config-fetch). BFF reads `process.env` directly with small per-key boot-time validators (no `@nestjs/config` overhead at this scale). The audit log uses a separate `AUDIT_DATABASE_URL` connection pool in production (`audit_writer`-only login, defense in depth) and falls back to the shared pool + `SET LOCAL ROLE` in dev — see [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md).
- **Internationalisation:** `@angular/localize` in build-time mode, two locales (`fr` default served at `/`, `en`), source locale = English (project English-only rule). Path-based URLs always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → `Accept-Language``fr`. UI strings live in XLIFF (`messages.fr.xlf`); editorial / CMS content is BFF-served already localised (see admin app). Footer hosts the locale switcher; switching writes a `__Host-portal_locale` cookie and hard-refreshes — see [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md).
- **Admin application (`portal-admin`):** dedicated Angular SPA alongside `portal-shell`, sharing the same `portal-bff` via `/api/admin/*` routes guarded by an Entra `Portal.Admin` role + `@RequireMfa({ freshness: 600 })` at entry. Distinct origin / cookie / session from `portal-shell` (`__Host-portal_admin_session`). v1 modules: CMS for static pages (multilingual), menu management, user list (read-only), audit log viewer. Bundle budget relaxed to ≤ 500 KB gzip (vs 300 KB for `portal-shell`); same a11y + dark-mode baseline. Shared UI primitives (`Icon`, `LayoutStateService`, brand tokens) graduate to `libs/shared/*` as both apps need them — see [ADR-0020](docs/decisions/0020-portal-admin-app.md).
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md). - **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md).
- **Documentation site:** `docs/**/*.md` rendered as a separate static site via **VitePress** (Vite-based, Node-only toolchain, Markdown-first). Mermaid diagrams via `vitepress-plugin-mermaid`. Deployed on its own hostname behind the shared reverse-proxy; CI hook on `docs/` changes rebuilds + publishes. Decoupled from the apps — content lives in `docs/`, no in-app Markdown viewer — see [ADR-0022](docs/decisions/0022-docs-site-vitepress.md).
- **Charts + dashboards:** `D3 + Observable Plot` wrapped in `libs/shared/charts/`, one Angular component per chart type (bar, donut, line, stacked-bar, …). A11y baked in by the lib (SVG `<title>`/`<desc>`, `<details>` tabular fallback, colour-blind-safe palettes, AA-contrast text, `prefers-reduced-motion` gate). Bundle stays under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s lazy-chunk cap via per-`d3-*` module tree-shaking. Future bespoke visualisations land in raw D3 inside the same lib — see [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md).
- **AI service relay:** dedicated `apf-ai-service` repo (ASP.NET Core, Microsoft Agent Framework) consumed via native gRPC HTTP/2 only — proto contract vendored under `apps/portal-bff/src/grpc/proto/apf-ai/` with `ts-proto` codegen committed alongside. BFF dials with `@grpc/grpc-js` (h2c in dev, h2 + TLS in prod), bridges `ChatService.Chat` to `text/event-stream` for the SPA, exposes `RagService.Search` and `ModelsService.ListModels` as plain JSON endpoints. Identity travels as an unsigned `Principal` (subject, roles, attributes) in the proto body for the POC, hashed via the audit module's `HashUserIdService` so portal and AI service audit trails join on the same `actor_id_hash`. Production hardening (signed envelope vs mTLS) deferred — see [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md).
- **Authorization model:** three orthogonal axes — **privileges** (Entra app roles, `Portal.*`), **functional roles** (Entra security groups → curated `apf-role-*` slug catalogue, 24 entries v1), **scopes** (portal-side `user_scopes` table, future Pléiades feed; kinds = `self / etablissement:<structure-code> / delegation:<dept> / region:<insee> / siege / unrestricted`, see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) for the `Structure.code` semantics). Composed at sign-in into a session-resident `Principal`; portal guards consume the structured shape, a deterministic `PrincipalProjector` flattens it to the AI-service `roles[]` contract. Replaces stargate's linear hierarchy. Catalogues are closed-set, drift gated by CI — see [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
- **Portal-side identity model:** `Person` golden record (stable identity, can exist without a portal account — workforce pre-provisioning, dossier bénéficiaires, alumni) + `User` overlay (one-to-zero-or-one with Person, lazy-created on first OIDC callback in v1; carries portal-only state like `lastSignInAt`). `UserScope` backs the ADR-0025 scope axis with opaque `value` strings referencing ADR-0027's `Structure.code` / `Delegation.code` / `Region.code` — no FK at the DB level so historical rows survive structure decommissioning; admin-UI write path validates. v1 dedup uses `entraOid` only; `Person.email` is an indexed attribute, not a unique key, because two distinct humans genuinely share emails (shared aliases, generic `info@`, upstream-feed errors). Facets (Salarié / Élu / Adhérent / Bénéficiaire) + Pléiades / Acteurs+ sync + operator-confirmed Person-merge flow deferred to ADR-0029 — see [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md).
- **Portal-side organisational hierarchy:** `Region` (INSEE 2-digit) → `Delegation` (department 23-char) → `Structure` with `kind` discriminator (`medico_social` / `antenne` / `dispositif` / `entreprise_adaptee` / `mouvement` / `administratif` / `siege`, aligned with cascade's `Structure.type`). `Structure.code` is the portal-internal string PK, externally meaningful: for medico-social rows it equals the FINESS (9 digits) and round-trips cleanly through scope literals (`etablissement:0330800013`) and URLs; for non-FINESS rows it is an APF-internal slug (`siege`, `apf-bdx-merignac`, `ea-toulouse`, …). `finess` / `siret` / `codePaie` are nullable, unique-when-present attributes — populated where the upstream registry has the structure on file. v1 ships a small inline-migration seed (test-tenant scope: Région Nouvelle-Aquitaine, Délégation 33, a handful of médico-social + siège); the full cascade-driven inventory sync, plus `Pole` / `Service` / arbitrary nesting / per-source enrichment, land additively with ADR-0029 — see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md).
- **Runtime:** Node.js latest LTS major. - **Runtime:** Node.js latest LTS major.
- **Local dev environment:** three coexisting run modes — native `pnpm nx serve`, the VSCode devcontainer, and a Docker Compose `apps` profile that boots all three Nx dev servers without a native Node/pnpm toolchain (`./infra/local/dev.sh up apps`). Dev-only (production images deferred to the ADR-0028 Container Registry work); shared `Dockerfile.dev`, repo bind-mounted for hot reload, `node_modules`/Nx cache in named volumes, BFF entrypoint runs `prisma generate` + `migrate deploy` — see [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md).
## Repository status ## Repository status
The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the seven lib roots (`libs/feature/auth`, `libs/shared/auth`, `libs/shared/charts`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` plus the ADR-0025 catalogue-drift gate on every PR. The Nx workspace is **not yet bootstrapped** — there is no `package.json`, no source code, no tests. ADRs 0001 → 0017 cover the structural, security, observability, and quality choices for phase-1, phase-2, and phase-3a. The security baseline ADR is **paused** awaiting RSSI input on the OWASP ASVS reference level and adjacent frameworks (HDS, GDPR, possibly PCI DSS / NIS 2). The next step is to scaffold the workspace per [docs/setup/03-angular-nx-monorepo.md](docs/setup/03-angular-nx-monorepo.md), which has already been rewritten to align with the phase-1 ADRs.
ADRs 0001 → 0028 plus ADR-0030 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, authorization, portal-side identity + organisational hierarchy, CI/CD platform migration, and dockerised local-dev mode choices. ADR-0028 supersedes only ADR-0015's "Gitea Actions" platform choice — the rest of ADR-0015's architectural principles carry over unchanged; the 4-phase migration (mirror → parallel pipelines → cutover → cleanup) ships in follow-up PRs. ADR-0029 (cascade / Pléiades / Acteurs+ syncs + facet schemas) is the next proposed addition — its number is reserved ahead of ADR-0030, which was written first. Until ADR-0026 + ADR-0027 implementation PRs ship, ADR-0025's stubs (`Principal.user.{id, personId}` placeholders, `StubScopeResolver`'s `unrestricted` blanket return) remain in place. **Shipped on `main`:** If asked to "build", "test", or "run" anything, first verify whether the workspace exists; if not, the right response is to scaffold it, not to invent commands.
- **Phase-1 foundation** — Nx workspace, Angular `portal-shell`, NestJS `portal-bff`, Prisma + Postgres, Pino + OpenTelemetry, Husky/lint-staged/commitlint, Gitea Actions CI.
- **Phase-2 auth + audit + security** — OIDC Auth Code + PKCE via MSAL Node, Redis sessions with AES-256-GCM at rest, idle 30 min sliding + absolute 12 h hard ceiling, RP-initiated logout, double-submit CSRF, `audit.events` append-only schema with role-based grants, helmet + env-driven CORS allowlist + rate limiting + structured error envelope (see [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)).
- **Phase-3a admin app** — `portal-admin` SPA with brand tokens, routing, user-list reader (`/admin/users`), and audit-log viewer with statistics and integrated charts (`/admin/audit`). CMS for static pages and menu management not yet implemented.
- **AI relay surface + live consumer** — vendored protos + `AiClientModule` (gRPC clients, Principal mapper, metadata builder) + `AiBridgeController` exposing `POST /api/ai/chat` (SSE), `GET /api/ai/rag/search`, `GET /api/ai/models` (see [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)). Chatbot widget live in `portal-shell` at `apps/portal-shell/src/app/features/chatbot/`.
- **Docs static site** ([ADR-0022](docs/decisions/0022-docs-site-vitepress.md)) — VitePress + Mermaid renderer at `docs/.vitepress/`, dedicated `docs-site.yml` workflow that rebuilds + publishes on every `docs/**` change.
- **Charts lib + audit-page dashboards** ([ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md)) — `libs/shared/charts/` with `BarChart`, `DonutChart`, `StackedBarChart` (D3 + Observable Plot, headless / a11y-baked-in), integrated into the `/admin/audit` page for daily-volume + outcome-breakdown + event-type-over-time views.
- **Authorization model + guards** ([ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)) — `libs/shared/auth/` exporting the closed catalogues (4 privileges, 24 functional roles, 6 scope kinds), `Principal` shape, pure matchers, and `EntraGroupToRoleResolver`. BFF-side `PrincipalBuilder` composing the three axes at sign-in from Entra `roles` + `groups` claims + a stub `ScopeResolver`. `@RequirePrivilege` / `@RequireRole` / `@RequireScope` route decorators + guards with the ADR-0021 structured-error envelope on denial; `AdminRoleGuard` migrated to read `principal.privileges`. CI drift gate (`scripts/check-catalogue-drift.mjs`) asserting every decorator literal is in the catalogue.
**Still on the roadmap:**
- `DownstreamApiClient` + OBO ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — module scaffolded (`obo.strategy`, `signed-assertion.strategy`, JWKS publisher, encrypted token cache); no v1 consumer yet. Wires in when the first business route needs an Entra-protected API.
- `@RequireMfa()` step-up consumer routes ([ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md)) — guard + decorator shipped; awaiting first sensitive route that needs explicit freshness enforcement beyond the Conditional Access baseline.
- **`@RequireScope` Prisma-backed resolver + first consumer surface** — `StubScopeResolver` returns `unrestricted` for everyone in v1 per [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) §331. Implementation lands across [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) (accepted: `Person` + `User` + `UserScope`) and [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) (accepted: `Region` / `Delegation` / `Structure` with `kind` discriminator + nullable FINESS / SIRET). Sequencing: ADR-0026 PR 1 + ADR-0027 PR 1 ship schema in parallel; ADR-0026 PR 2 then lands the `PrismaScopeResolver` + admin scope-seeding UI + test-tenant seed (which references ADR-0027's `Structure.code` values). The follow-up [ADR-0029](#) covers Pléiades / Acteurs+ / cascade syncs + facet schemas.
- **Proto-drift CI gate for the AI relay** ([ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)) — asserts the vendored `apf-ai-service` proto files stay in lockstep with the upstream contract.
- **Admin app — CMS & menu management** ([ADR-0020](docs/decisions/0020-portal-admin-app.md)) — multilingual static-page editor + navigation menu builder. The user-list + audit-log-viewer modules already exist; the CMS/menu pair is the remaining v1 module scope.
- **Strategic security baseline ADR** — separate from the implementation-level [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md). Remains **paused** awaiting RSSI input on the OWASP ASVS reference level and adjacent frameworks (HDS, GDPR, possibly NIS 2). When it lands it will either confirm 0021 or supersede pieces of it.
## Commands once the workspace exists ## Commands once the workspace exists
App-scoped — `<app>` is one of `portal-shell`, `portal-admin`, `portal-bff`: App-scoped — `<app>` is one of `portal-shell`, `portal-bff`:
```bash ```bash
pnpm nx serve <app> # dev server pnpm nx serve <app> # dev server
@@ -109,10 +82,8 @@ pnpm nx format:check
## Environment conventions ## Environment conventions
- **Two development environments.** `local` (Windows-WSL or native macOS / Linux on the workstation) and `development` (Debian 13 VM at `10.100.201.21` — the default for new devs, replaces WSL). A `hybrid` sub-mode runs the IDE + Nx servers on the workstation while reaching the infra services (postgres / redis / otel) on the dev VM through SSH tunnels. Full procedure: [docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md). The legacy WSL flow remains documented in [docs/setup/02-wsl-terminal-setup.md](docs/setup/02-wsl-terminal-setup.md).
- **Two IDE flows on the dev VM** — VSCode Remote-SSH (default; transparent equivalent of the WSL flow) and Devcontainer (`.devcontainer/devcontainer.json` shipped, Node + pnpm pinned in the image, mounts the docker socket so the host's `apf-portal-dev` Compose network is reachable from inside). Independently of the IDE flow, the apps can run **natively** (`pnpm nx serve`) or as **Docker Compose services** (`./infra/local/dev.sh up apps`, no native toolchain — ADR-0030); the "which mode when" table is in [docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md).
- **Never install Angular globally.** Use `pnpm dlx` for one-off CLI invocations and project-local `pnpm nx ...` for everything else — versions stay pinned per project. - **Never install Angular globally.** Use `pnpm dlx` for one-off CLI invocations and project-local `pnpm nx ...` for everything else — versions stay pinned per project.
- **On WSL: work inside the WSL filesystem** (`~/Works/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times. On the dev VM the analogous rule is "stay on the VM disk, do not work over SSHFS / network mounts". - **Work inside the WSL filesystem** (`~/dev/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times.
- **pnpm is mandatory** (activated via `corepack enable`); do not introduce npm or yarn lockfiles. - **pnpm is mandatory** (activated via `corepack enable`); do not introduce npm or yarn lockfiles.
- Prettier config target: `singleQuote: true`, `semi: true`, `printWidth: 100`. - Prettier config target: `singleQuote: true`, `semi: true`, `printWidth: 100`.
-12
View File
@@ -1,12 +0,0 @@
import playwright from 'eslint-plugin-playwright';
import baseConfig from '../../eslint.config.mjs';
export default [
playwright.configs['flat/recommended'],
...baseConfig,
{
files: ['**/*.ts', '**/*.js'],
// Override or add rules here
rules: {},
},
];
@@ -1,68 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import { nxE2EPreset } from '@nx/playwright/preset';
import { workspaceRoot } from '@nx/devkit';
// For CI, you may want to set BASE_URL to the deployed application.
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
...nxE2EPreset(__filename, { testDir: './src' }),
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Run your local dev server before starting the tests */
webServer: {
command: 'pnpm exec nx run portal-admin:serve',
url: 'http://localhost:4200',
reuseExistingServer: true,
cwd: workspaceRoot,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Uncomment for mobile browsers support
/* {
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
}, */
// Uncomment for branded browsers
/* {
name: 'Microsoft Edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
} */
],
});
-9
View File
@@ -1,9 +0,0 @@
{
"name": "portal-admin-e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/portal-admin-e2e/src",
"implicitDependencies": ["portal-admin"],
"// targets": "to see all targets run: nx show project portal-admin-e2e --web",
"targets": {}
}
@@ -1,8 +0,0 @@
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('/');
// Expect h1 to contain a substring.
expect(await page.locator('h1').innerText()).toContain('Welcome');
});
-24
View File
@@ -1,24 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"outDir": "../../dist/out-tsc",
"sourceMap": false,
"module": "commonjs",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"**/*.ts",
"**/*.js",
"playwright.config.ts",
"src/**/*.spec.ts",
"src/**/*.spec.js",
"src/**/*.test.ts",
"src/**/*.test.js",
"src/**/*.d.ts"
]
}
-5
View File
@@ -1,5 +0,0 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
-34
View File
@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
-130
View File
@@ -1,130 +0,0 @@
{
"name": "portal-admin",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/portal-admin/src",
"tags": ["scope:portal-admin", "type:app"],
"i18n": {
"sourceLocale": {
"code": "en",
"baseHref": "/en/"
},
"locales": {
"fr": {
"translation": "apps/portal-admin/src/locale/messages.fr.xlf",
"baseHref": "/fr/"
}
}
},
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/portal-admin",
"browser": "apps/portal-admin/src/main.ts",
"polyfills": ["@angular/localize/init"],
"tsConfig": "apps/portal-admin/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "apps/portal-admin/public"
}
],
"styles": ["apps/portal-admin/src/styles.css"]
},
"configurations": {
"production": {
"localize": ["en", "fr"],
"i18nMissingTranslation": "error",
"budgets": [
{
"type": "initial",
"maximumWarning": "1.5mb",
"maximumError": "1.5mb"
},
{
"type": "anyScript",
"maximumWarning": "500kb",
"maximumError": "500kb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "8kb"
},
{
"type": "bundle",
"name": "styles",
"maximumWarning": "150kb",
"maximumError": "150kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"extract-i18n": {
"executor": "@angular/build:extract-i18n",
"options": {
"buildTarget": "portal-admin:build",
"outputPath": "apps/portal-admin/src/locale",
"outFile": "messages.xlf"
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"options": {
"port": 4300,
"proxyConfig": "apps/portal-admin/proxy.conf.js"
},
"configurations": {
"production": {
"buildTarget": "portal-admin:build:production"
},
"development": {
"buildTarget": "portal-admin:build:development"
},
"https": {
"buildTarget": "portal-admin:build:development",
"ssl": true,
"sslKey": ".secrets/dev-tls.key",
"sslCert": ".secrets/dev-tls.pem"
}
},
"defaultConfiguration": "development"
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
},
"configurations": {
"watch": {
"watch": true
}
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "portal-admin:build",
"port": 4300,
"staticFilePath": "dist/apps/portal-admin/browser"
}
}
}
}
-21
View File
@@ -1,21 +0,0 @@
// Angular dev-server proxy for portal-admin.
//
// Mirrors apps/portal-shell/proxy.conf.js — same rationale, same
// `/api → ${BFF_TARGET:-http://localhost:3000}` rule. The admin app
// talks to the same BFF (ADR-0020 §"Where does the admin app live"),
// just at admin-specific paths under `/api/admin/...`; the proxy
// match on `/api` covers both surfaces.
//
// JS form deliberate — only this form can read `process.env` so the
// Docker / native target swap (BFF_TARGET in dev.compose.yml) works
// without a rebuild.
const target = process.env['BFF_TARGET'] ?? 'http://localhost:3000';
module.exports = {
'/api': {
target,
secure: false,
changeOrigin: true,
},
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 92 KiB

@@ -1,21 +0,0 @@
{
"name": "APF Portal Admin",
"short_name": "Admin",
"icons": [
{
"src": "/favicons/web-app-manifest-192x192.png?v=20260511",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/favicons/web-app-manifest-512x512.png?v=20260511",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravitdesign.com --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1024 1024" width="1024pt" height="1024pt"><defs><clipPath id="_clipPath_B4TZ570FigxU4c50hle9QrZwbv6YvFBZ"><rect width="1024" height="1024"/></clipPath></defs><g clip-path="url(#_clipPath_B4TZ570FigxU4c50hle9QrZwbv6YvFBZ)"><path d=" M 302.7 566.118 C 355.817 515.422 427.036 483.173 501.691 480.62 C 626.026 477.14 721.654 557.185 738.018 669.829 C 734.571 656.604 628.618 409.16 302.701 566.118 L 302.7 566.118 Z " fill="rgb(255, 255, 255)"/><g><path d=" M 732.849 505.91 C 675.421 450.11 499.114 228.071 514.048 161.831 C 530.416 89.558 636.371 30.51 735.43 0 C 719.64 2.552 706.724 5.104 694.661 8.585 C 586.122 37.355 472.984 99.302 454.893 175.868 C 439.96 241.297 498.247 315.31 562.572 378.65 C 584.25 400.069 569.598 402.126 540.415 406.219 C 515.859 409.664 481.014 414.554 448.867 433.637 C 527.084 402.036 655.737 470.669 711.434 500.382 C 735.478 513.208 745.922 518.782 732.849 505.91 L 732.849 505.91 Z " fill="rgb(255, 255, 255)"/><path d=" M 711.885 266.354 C 737.15 226.331 785.688 206.494 821.289 222.039 C 856.897 237.816 865.799 283.06 841.394 323.894 C 816.989 363.917 768.451 384.102 732.85 368.325 C 697.242 352.78 687.774 307.538 711.885 266.354 Z " fill="rgb(255, 255, 255)"/><path d=" M 307.282 582.591 C 617.41 441.409 727.668 675.86 732.836 700.109 C 735.416 720.173 735.416 742.1 731.969 763.679 C 711.878 907.414 578.07 1024 433.052 1024 C 288.045 1024 187.249 907.414 207.353 763.679 C 217.694 693.146 255.022 629.459 307.282 582.591 L 307.282 582.591 Z M 450.565 937.11 C 546.769 937.11 636.357 859.614 649.278 763.679 C 663.344 668.089 595.583 590.364 499.101 590.364 C 467.005 590.364 435.702 598.966 407.73 613.965 C 388.702 624.169 371.215 637.334 356.069 652.766 C 326.553 682.839 305.925 721.522 300.1 763.679 C 286.323 859.614 354.084 937.11 450.566 937.11 L 450.565 937.11 Z " fill-rule="evenodd" fill="rgb(255, 255, 255)"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

-49
View File
@@ -1,49 +0,0 @@
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: [
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 },
],
};
-9
View File
@@ -1,9 +0,0 @@
<a class="skip-link" href="#main-content" i18n="@@app.skipLink">Skip to main content</a>
<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 />
-48
View File
@@ -1,48 +0,0 @@
import { Route } from '@angular/router';
import { authGuard } from 'feature-auth';
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
const auditTitle = $localize`:@@route.audit.title:Audit log — APF Portal Admin`;
const usersTitle = $localize`:@@route.users.title:Users — APF Portal Admin`;
const userScopesTitle = $localize`:@@route.user-scopes.title:User scopes — APF Portal Admin`;
const profileTitle = $localize`:@@route.profile.title:Profile — APF Portal Admin`;
export const appRoutes: Route[] = [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./pages/home/home').then((m) => m.Home),
title: homeTitle,
},
{
path: 'audit',
loadComponent: () => import('./pages/audit/audit').then((m) => m.AuditPage),
title: auditTitle,
},
{
path: 'users',
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),
title: usersTitle,
},
{
path: 'users/:oid/scopes',
loadComponent: () => import('./pages/user-scopes/user-scopes').then((m) => m.UserScopesPage),
title: userScopesTitle,
},
{
path: 'profile',
canActivate: [authGuard],
loadComponent: () => import('./pages/profile/profile').then((m) => m.ProfilePage),
title: profileTitle,
},
// Catch-all — unknown paths bounce to home. Same role as the
// wildcard in portal-shell (per PR #96): in production each locale
// bundle ships with its own `<base href="/{locale}/">` and the
// router never sees the locale segment, so this only fires for
// genuine 404 paths; in dev (`nx serve`) it stops the router from
// throwing NG04002 when a stray `/fr/`-style URL is typed.
{
path: '**',
redirectTo: '',
},
];
-55
View File
@@ -1,55 +0,0 @@
// 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
// visual state on focus is as predictable as possible across themes.
.skip-link {
position: absolute;
top: -40px;
left: 0.75rem;
z-index: 50;
padding: 0.5rem 0.75rem;
background: var(--color-brand-primary-500);
color: #fff;
border-radius: 0.25rem;
text-decoration: none;
transition: top 0.15s ease-out;
&:focus,
&:focus-visible {
top: 0.5rem;
outline: 2px solid var(--color-brand-accent-300);
outline-offset: 2px;
}
}
-33
View File
@@ -1,33 +0,0 @@
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([]),
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 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();
});
});
-14
View File
@@ -1,14 +0,0 @@
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, AdminHeader, AdminSidebar, AdminFooter],
templateUrl: './app.html',
styleUrl: './app.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {}
@@ -1,4 +0,0 @@
<footer class="admin-footer">
<span class="copyright">© {{ year }} APF France handicap</span>
<span class="surface-tag">Admin surface</span>
</footer>
@@ -1,29 +0,0 @@
.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);
}
@@ -1,26 +0,0 @@
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');
});
});
@@ -1,22 +0,0 @@
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();
}
@@ -1,44 +0,0 @@
<header class="admin-header">
<a class="brand" routerLink="/">
<img
src="logos/apf-logo.svg"
alt=""
aria-hidden="true"
width="28"
height="28"
class="brand-logo"
/>
<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') {
<lib-user-menu
[displayName]="state.user.displayName"
[username]="state.user.username"
[initials]="initials()"
[items]="userMenuItems"
[role]="roleLabel"
[signedInAsLabel]="signedInAsLabel"
[signOutLabel]="signOutLabel"
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
(signOut)="signOut()"
data-testid="user-menu"
/>
} } } }
</div>
</header>
@@ -1,126 +0,0 @@
// 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;
}
}
.brand-logo {
display: block;
flex-shrink: 0;
height: 1.75rem;
width: 1.75rem;
}
// "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;
// The admin header sits on `--color-brand-primary-600`; a brand
// primary avatar inside the user menu trigger would barely read.
// Override the shared avatar tokens to a translucent white block
// matching the inline avatar this widget replaced.
lib-user-menu {
--user-menu-avatar-bg: rgba(255, 255, 255, 0.2);
--user-menu-avatar-fg: #ffffff;
}
}
.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);
}
}
@@ -1,111 +0,0 @@
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 menu trigger with initials when authenticated', async () => {
const { fixture } = await setup({ onBootstrap: 'authenticated' });
const root = fixture.nativeElement as HTMLElement;
const menu = root.querySelector('[data-testid="user-menu"]');
expect(menu).not.toBeNull();
expect(menu?.querySelector('.user-menu__avatar')?.textContent?.trim()).toBe('AS');
});
it('opens the menu and exposes displayName + Sign out inside the panel', async () => {
const { fixture } = await setup({ onBootstrap: 'authenticated' });
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'[data-testid="user-menu"] button[aria-haspopup="menu"]',
);
trigger?.click();
fixture.detectChanges();
await fixture.whenStable();
const panel = document.body.querySelector('.user-menu__panel');
expect(panel).not.toBeNull();
expect(panel?.querySelector('.user-menu__header-name')?.textContent).toContain('Admin Smith');
expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull();
});
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 inside the menu panel', async () => {
const { fixture, navigate } = await setup({ onBootstrap: 'authenticated' });
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'[data-testid="user-menu"] button[aria-haspopup="menu"]',
);
trigger?.click();
fixture.detectChanges();
await fixture.whenStable();
document.body.querySelector<HTMLButtonElement>('.user-menu__item--sign-out')?.click();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/logout`);
});
});
@@ -1,87 +0,0 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthService, type CurrentUser } from 'feature-auth';
import { UserMenu, type UserMenuItem } from 'shared-ui';
import { environment } from '../../../environments/environment';
/**
* 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, UserMenu],
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) : '';
});
// Static menu rows. Same shape as the portal-shell menu so a user
// who learned one finds the other familiar. The "Open Portal
// Shell" entry is unconditional here — the user-portal is public,
// any admin can jump back to it.
protected readonly userMenuItems: readonly UserMenuItem[] = [
{ label: 'Profile', icon: 'user', routerLink: '/profile' },
{ label: 'Settings', icon: 'settings', disabled: true, badge: 'Soon' },
{ label: 'Open Portal Shell', icon: 'home', href: environment.shellAppUrl },
];
protected readonly signedInAsLabel = 'Signed in as';
protected readonly signOutLabel = 'Sign out';
// Static role label — every reader who reaches portal-admin holds
// the `Portal.Admin` Entra app role (that's the `AdminRoleGuard`
// precondition for `/api/admin/*`), so the chip is unconditional
// and doesn't need to read `req.session.user.roles`. No i18n marks
// either, per ADR-0020's source-locale-only chrome.
protected readonly roleLabel = 'Administrator';
protected triggerAriaLabel(displayName: string): string {
return `User menu — signed in as ${displayName}`;
}
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();
}
@@ -1,25 +0,0 @@
<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>
@@ -1,112 +0,0 @@
// The host element owns the sidebar envelope (width + chrome) so
// the gray background + right border cover the full column, not
// just the rail of links. The inner <nav> handles the scrolling
// list inside that envelope.
:host {
display: flex;
flex-direction: column;
width: 16rem;
flex: 0 0 16rem;
background-color: #f3f4f6;
border-right: 1px solid #e5e7eb;
}
:host-context(.dark) {
background-color: #0f172a;
border-right-color: #1f2937;
color: #e5e7eb;
}
.admin-sidebar {
flex: 1 1 auto;
padding: 0.75rem 0;
overflow-y: auto;
}
.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);
}
@@ -1,51 +0,0 @@
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('exposes the User list entry as a live router link', () => {
const root = render();
const links = Array.from(root.querySelectorAll<HTMLAnchorElement>('a.nav-link'));
const usersLink = links.find((a) => a.textContent?.includes('User list'));
expect(usersLink).toBeTruthy();
expect(usersLink?.getAttribute('href')).toBe('/users');
});
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');
}
});
});
@@ -1,42 +0,0 @@
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: '/users' },
];
@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,90 +0,0 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import { AuditEventsService, type AdminAuditPage } from './audit-events.service';
const BFF_BASE = 'http://bff.test/api';
const AUDIT_URL = `${BFF_BASE}/admin/audit`;
const PAGE: AdminAuditPage = {
items: [
{
id: '11111111-1111-1111-1111-111111111111',
createdAt: '2026-05-13T10:30:00.000Z',
eventType: 'auth.sign_in',
audience: 'workforce',
actorIdHash: 'hash(jane)',
traceId: 'trace-abc',
subject: 'session:sid-1',
outcome: 'success',
payload: { amr: ['pwd', 'mfa'] },
},
],
total: 1,
limit: 50,
offset: 0,
};
function setup() {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
],
});
return {
service: TestBed.inject(AuditEventsService),
http: TestBed.inject(HttpTestingController),
};
}
describe('AuditEventsService.query', () => {
it('GETs /admin/audit with the populated filter fields as URL params', async () => {
const { service, http } = setup();
const promise = firstValueFrom(
service.query({
eventType: 'auth.sign_in',
audience: 'workforce',
outcome: 'success',
limit: 25,
offset: 50,
}),
);
const req = http.expectOne((r) => r.url === AUDIT_URL);
expect(req.request.method).toBe('GET');
expect(req.request.params.get('eventType')).toBe('auth.sign_in');
expect(req.request.params.get('audience')).toBe('workforce');
expect(req.request.params.get('outcome')).toBe('success');
expect(req.request.params.get('limit')).toBe('25');
expect(req.request.params.get('offset')).toBe('50');
req.flush(PAGE);
await expect(promise).resolves.toEqual(PAGE);
});
it('omits filter fields that are empty strings (BFF rejects `?foo=` as invalid)', async () => {
const { service, http } = setup();
void firstValueFrom(
service.query({
eventType: '',
subjectPrefix: '',
limit: 50,
}),
);
const req = http.expectOne((r) => r.url === AUDIT_URL);
expect(req.request.params.has('eventType')).toBe(false);
expect(req.request.params.has('subjectPrefix')).toBe(false);
expect(req.request.params.get('limit')).toBe('50');
req.flush(PAGE);
});
it('omits undefined filter fields', async () => {
const { service, http } = setup();
void firstValueFrom(service.query({}));
const req = http.expectOne((r) => r.url === AUDIT_URL);
expect(req.request.params.keys().length).toBe(0);
req.flush(PAGE);
});
});
@@ -1,116 +0,0 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import type { Observable } from 'rxjs';
/**
* One row of the BFF's `GET /api/admin/audit` response. Mirrors
* `AdminAuditEventDto` on the BFF side — ISO-string timestamp,
* already-parsed JSON payload, hashed actor id.
*/
export interface AdminAuditEvent {
readonly id: string;
readonly createdAt: string;
readonly eventType: string;
readonly audience: string;
readonly actorIdHash: string | null;
readonly traceId: string | null;
readonly subject: string | null;
readonly outcome: string;
readonly payload: Record<string, unknown> | null;
}
/** Paginated response shape — mirrors `AdminAuditPage` on the BFF. */
export interface AdminAuditPage {
readonly items: readonly AdminAuditEvent[];
readonly total: number;
readonly limit: number;
readonly offset: number;
}
/**
* Aggregated audit-event stats — mirrors `AdminAuditStats` on the
* BFF side. Powers the Charts tab of the audit viewer.
*/
export interface AdminAuditStats {
readonly dailyVolume: ReadonlyArray<{ day: string; count: number }>;
readonly outcomeBreakdown: ReadonlyArray<{ outcome: string; count: number }>;
readonly eventTypeByDay: ReadonlyArray<{ day: string; eventType: string; count: number }>;
readonly total: number;
}
/**
* Filter shape sent as query params on the stats endpoint. Same as
* {@link AdminAuditQuery} minus pagination — the stats endpoint
* always aggregates the whole filtered set.
*/
export type AdminAuditStatsQuery = Omit<AdminAuditQuery, 'limit' | 'offset'>;
/**
* Filter+pagination shape sent as query params. All fields are
* optional; missing values fall back to the BFF defaults (limit=50,
* offset=0, no filter).
*/
export interface AdminAuditQuery {
// Both optional AND explicitly nullable: `exactOptionalPropertyTypes`
// rejects assigning a `T | undefined` to a `?: T`, but the
// AuditPage `buildFilters()` builds objects with `undefined`
// placeholders for missing user input. Allowing both `?:` and
// `| undefined` lets callers omit the key entirely OR set it to
// undefined, both meaning "no filter".
readonly eventType?: string | undefined;
readonly actorIdHash?: string | undefined;
readonly audience?: 'workforce' | 'customer' | undefined;
readonly outcome?: 'success' | 'failure' | 'denied' | undefined;
readonly subjectPrefix?: string | undefined;
readonly createdAtFrom?: string | undefined;
readonly createdAtTo?: string | undefined;
readonly limit?: number | undefined;
readonly offset?: number | undefined;
}
/**
* Thin HttpClient wrapper. Lives in `providedIn: 'root'` because the
* audit page is the single v1 consumer and per-page DI overhead is
* not worth it. The wrapper exists at all (vs HttpClient directly in
* the component) so the audit page's logic stays signal-only and the
* spec can mock the service rather than threading
* `HttpTestingController` through every assertion.
*/
@Injectable({ providedIn: 'root' })
export class AuditEventsService {
private readonly http = inject(HttpClient);
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
query(filters: AdminAuditQuery): Observable<AdminAuditPage> {
const params = this.toHttpParams({ ...filters });
return this.http.get<AdminAuditPage>(`${this.bffBaseUrl}/admin/audit`, { params });
}
/**
* Calls `GET /api/admin/audit/stats` with the same filter shape
* minus pagination. The endpoint Redis-caches results for 5
* minutes per filter-hash (see ADR-0013 §"Reader endpoints"); the
* SPA itself doesn't try to cache.
*/
stats(filters: AdminAuditStatsQuery): Observable<AdminAuditStats> {
const params = this.toHttpParams({ ...filters });
return this.http.get<AdminAuditStats>(`${this.bffBaseUrl}/admin/audit/stats`, { params });
}
private toHttpParams(filters: Record<string, unknown>): HttpParams {
let params = new HttpParams();
for (const [key, value] of Object.entries(filters)) {
// Drop empty strings — Nest's ValidationPipe treats `?foo=`
// as `foo === ''` which trips the IsISO8601 / IsString
// validators and surfaces as a 400. The SPA passes empty
// inputs when the user hasn't filled a filter; this strips
// them out so the BFF only sees populated fields.
if (value === undefined || value === null || value === '') {
continue;
}
params = params.set(key, String(value));
}
return params;
}
}
@@ -1,312 +0,0 @@
<section class="audit">
<header class="audit-header">
<h1 class="title">Audit log</h1>
<p class="intro">
Read-only view of <code>audit.events</code> per
<a
href="https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0013-audit-trail-separated-postgres-append-only.md"
rel="noopener"
target="_blank"
>ADR-0013</a
>. Every query run on this page emits its own <code>admin.audit.query</code> row — read access
is itself auditable.
</p>
</header>
<form class="filters" (submit)="$event.preventDefault(); search()" aria-labelledby="filters-h">
<h2 id="filters-h" class="filters-heading">Filters</h2>
<div class="filters-grid">
<label class="filter">
<span class="filter-label">Event type</span>
<input
type="text"
name="eventType"
[ngModel]="eventType()"
(ngModelChange)="eventType.set($event)"
placeholder="auth.sign_in"
/>
</label>
<label class="filter">
<span class="filter-label">Actor id hash</span>
<input
type="text"
name="actorIdHash"
[ngModel]="actorIdHash()"
(ngModelChange)="actorIdHash.set($event)"
placeholder="64 hex chars"
/>
</label>
<label class="filter">
<span class="filter-label">Audience</span>
<select name="audience" [ngModel]="audience()" (ngModelChange)="audience.set($event)">
<option value="">Any</option>
<option value="workforce">workforce</option>
<option value="customer">customer</option>
</select>
</label>
<label class="filter">
<span class="filter-label">Outcome</span>
<select name="outcome" [ngModel]="outcome()" (ngModelChange)="outcome.set($event)">
<option value="">Any</option>
<option value="success">success</option>
<option value="failure">failure</option>
<option value="denied">denied</option>
</select>
</label>
<label class="filter">
<span class="filter-label">Subject prefix</span>
<input
type="text"
name="subjectPrefix"
[ngModel]="subjectPrefix()"
(ngModelChange)="subjectPrefix.set($event)"
placeholder="session:"
/>
</label>
<label class="filter">
<span class="filter-label">From</span>
<input
type="datetime-local"
name="createdAtFrom"
[ngModel]="createdAtFrom()"
(ngModelChange)="createdAtFrom.set($event)"
/>
</label>
<label class="filter">
<span class="filter-label">To</span>
<input
type="datetime-local"
name="createdAtTo"
[ngModel]="createdAtTo()"
(ngModelChange)="createdAtTo.set($event)"
/>
</label>
<label class="filter">
<span class="filter-label">Page size</span>
<select name="limit" [ngModel]="limit()" (ngModelChange)="limit.set(+$event)">
@for (size of pageSizes; track size) {
<option [value]="size">{{ size }}</option>
}
</select>
</label>
</div>
<div class="filters-actions">
<button type="submit" class="btn btn--primary" [disabled]="loading()">Apply filters</button>
<button
type="button"
class="btn btn--secondary"
(click)="clearFilters()"
[disabled]="loading()"
>
Reset
</button>
</div>
</form>
<div class="tablist" role="tablist" aria-label="Audit log views">
<button
type="button"
role="tab"
id="tab-table"
class="tab"
[class.tab--active]="tab() === 'table'"
[attr.aria-selected]="tab() === 'table'"
[attr.aria-controls]="'panel-table'"
[attr.tabindex]="tab() === 'table' ? 0 : -1"
(click)="setTab('table')"
(keydown.arrowRight)="setTab('charts')"
>
Table
</button>
<button
type="button"
role="tab"
id="tab-charts"
class="tab"
[class.tab--active]="tab() === 'charts'"
[attr.aria-selected]="tab() === 'charts'"
[attr.aria-controls]="'panel-charts'"
[attr.tabindex]="tab() === 'charts' ? 0 : -1"
(click)="setTab('charts')"
(keydown.arrowLeft)="setTab('table')"
>
Charts
</button>
</div>
<div class="status-bar" role="status" aria-live="polite">
@if (tab() === 'table') { @if (loading()) {
<span class="status-line">Loading…</span>
} @else if (error(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
} @else if (page(); as p) {
<span class="status-line">{{ resultRange() }}</span>
} } @else { @if (statsLoading()) {
<span class="status-line">Loading statistics…</span>
} @else if (statsError(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
} @else if (stats(); as s) {
<span class="status-line">{{ s.total }} matching event(s) aggregated</span>
} }
</div>
<section
id="panel-charts"
role="tabpanel"
aria-labelledby="tab-charts"
class="tab-panel"
[hidden]="tab() !== 'charts'"
>
@if (stats(); as s) { @if (hasChartData()) {
<p class="charts-note">
Aggregations are computed across the full filtered set (server-side), not just the events on
the current page. Results are cached for 5 minutes per filter combination.
</p>
<div class="charts-grid">
<div class="chart-tile">
<lib-bar-chart
[data]="s.dailyVolume"
xKey="day"
yKey="count"
xLabel="Date (UTC)"
yLabel="Events"
caption="Events per day"
description="Bar chart of audit event volume per calendar day across the full filtered set."
ariaLabel="Events per day — bar chart"
/>
</div>
<div class="chart-tile">
<lib-donut-chart
[data]="s.outcomeBreakdown"
categoryKey="outcome"
valueKey="count"
[centerLabel]="s.total.toString()"
[colorMap]="outcomeColorMap"
caption="Outcome breakdown"
description="Donut chart of audit event outcomes (success in green, denied in orange, failure in red) across the full filtered set."
ariaLabel="Outcome breakdown — donut chart"
/>
</div>
<div class="chart-tile chart-tile--wide">
<lib-stacked-bar-chart
[data]="s.eventTypeByDay"
xKey="day"
yKey="count"
seriesKey="eventType"
xLabel="Date (UTC)"
yLabel="Events"
caption="Events per day, by event type"
description="Stacked bar chart of audit event volume per calendar day, broken down by event_type, across the full filtered set."
ariaLabel="Events per day, by event type — stacked bar chart"
/>
</div>
</div>
} @else {
<p class="empty">No audit events match the current filters.</p>
} } @else if (!statsLoading() && !statsError()) {
<p class="charts-note">Switch to this tab to load statistics.</p>
}
</section>
<section
id="panel-table"
role="tabpanel"
aria-labelledby="tab-table"
class="tab-panel"
[hidden]="tab() !== 'table'"
>
@if (page(); as p) { @if (p.items.length === 0) {
<p class="empty">No audit events match the current filters.</p>
} @else {
<div class="table-wrap">
<table class="audit-table">
<thead>
<tr>
<th scope="col">Timestamp</th>
<th scope="col">Event</th>
<th scope="col">Audience / outcome</th>
<th scope="col">Actor / subject</th>
<th scope="col">Trace</th>
<th scope="col">Payload</th>
</tr>
</thead>
<tbody>
@for (event of p.items; track event.id) {
<tr>
<td class="cell-timestamp">{{ formatTimestamp(event.createdAt) }}</td>
<td class="cell-event">{{ event.eventType }}</td>
<td class="cell-aud">
<span class="aud-badge">{{ event.audience }}</span>
<span
class="outcome-badge"
[class.outcome-badge--success]="event.outcome === 'success'"
[class.outcome-badge--failure]="event.outcome === 'failure'"
[class.outcome-badge--denied]="event.outcome === 'denied'"
>{{ event.outcome }}</span
>
</td>
<td class="cell-actor">
@if (event.actorIdHash; as hash) {
<button
type="button"
class="actor-hash actor-hash--clickable"
(click)="filterByActor(hash)"
[attr.title]="'Filter the table on ' + hash"
>
{{ hash }}
</button>
} @else {
<div class="actor-hash">(anonymous)</div>
} @if (event.subject; as subject) {
<div class="subject">{{ subject }}</div>
}
</td>
<td class="cell-trace">
@if (event.traceId; as traceId) {
<a
class="trace-link"
[href]="jaegerUrl(traceId)"
target="_blank"
rel="noopener noreferrer"
[attr.title]="'Open trace ' + traceId + ' in Jaeger'"
>{{ traceId }}</a
>
} @else { — }
</td>
<td class="cell-payload">
@if (event.payload) {
<details>
<summary>view</summary>
<pre>{{ formatPayload(event.payload) }}</pre>
</details>
} @else {
<span class="dim"></span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<nav class="pagination" aria-label="Pagination">
<button
type="button"
class="btn btn--secondary"
(click)="previous()"
[disabled]="!hasPreviousPage() || loading()"
>
Previous
</button>
<button
type="button"
class="btn btn--secondary"
(click)="next()"
[disabled]="!hasNextPage() || loading()"
>
Next
</button>
</nav>
} }
</section>
</section>
@@ -1,552 +0,0 @@
.audit {
max-width: 1200px;
margin: 0 auto;
}
.audit-header {
margin-bottom: 1.5rem;
}
.title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.intro {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
code {
padding: 0.0625rem 0.25rem;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.05);
font-family: ui-monospace, monospace;
font-size: 0.875em;
}
a {
color: var(--color-brand-primary-600, #2563eb);
text-decoration: underline;
}
}
:host-context(.dark) .intro {
color: #9ca3af;
code {
background-color: rgba(255, 255, 255, 0.08);
}
a {
color: var(--color-brand-primary-300, #93c5fd);
}
}
.filters {
padding: 1rem;
margin-bottom: 1rem;
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
:host-context(.dark) .filters {
background-color: #111827;
border-color: #1f2937;
}
.filters-heading {
margin: 0 0 0.75rem;
font-size: 1rem;
font-weight: 600;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
.filter {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-label {
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
}
:host-context(.dark) .filter-label {
color: #9ca3af;
}
.filter input,
.filter select {
height: 2rem;
padding: 0 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
background-color: #ffffff;
color: inherit;
font-size: 0.875rem;
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 1px;
border-color: transparent;
}
}
:host-context(.dark) .filter input,
:host-context(.dark) .filter select {
background-color: #0f172a;
border-color: #374151;
color: #e5e7eb;
}
.filters-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.875rem;
}
.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;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&: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:not(:disabled) {
background-color: var(--color-brand-primary-700, #1d4ed8);
}
}
.btn--secondary {
background-color: transparent;
border-color: #d1d5db;
color: inherit;
&:hover:not(:disabled) {
background-color: rgba(0, 0, 0, 0.04);
}
}
:host-context(.dark) .btn--secondary:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.06);
}
.status-bar {
margin-bottom: 0.5rem;
min-height: 1.25rem;
font-size: 0.875rem;
color: #6b7280;
}
.status-line--error {
color: #b91c1c;
font-weight: 500;
}
:host-context(.dark) .status-bar {
color: #9ca3af;
}
:host-context(.dark) .status-line--error {
color: #fca5a5;
}
.empty {
padding: 1.5rem;
text-align: center;
color: #6b7280;
background-color: #ffffff;
border: 1px dashed #d1d5db;
border-radius: 0.5rem;
}
:host-context(.dark) .empty {
background-color: #111827;
border-color: #374151;
color: #9ca3af;
}
// Tabs above the content. WAI-ARIA pattern: `role="tablist"` on the
// container, `role="tab"` on each button, roving `tabindex` (the
// active tab is reachable via Tab, inactive ones via arrow keys
// once focus is in the list). The visual treatment is a thin
// underline on the active tab — minimal furniture, lets the panel
// content carry the eye.
.tablist {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 1rem;
}
:host-context(.dark) .tablist {
border-bottom-color: #1f2937;
}
.tab {
appearance: none;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
padding: 0.5rem 0.75rem;
margin-bottom: -1px;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
cursor: pointer;
transition:
color 0.12s ease-out,
border-color 0.12s ease-out;
&:hover {
color: var(--color-brand-primary-600, #1d4ed8);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
border-radius: 0.125rem;
}
}
:host-context(.dark) .tab {
color: #9ca3af;
&:hover {
color: var(--color-brand-primary-300, #93c5fd);
}
}
.tab--active {
color: var(--color-brand-primary-600, #1d4ed8);
border-bottom-color: var(--color-brand-primary-600, #1d4ed8);
font-weight: 600;
}
:host-context(.dark) .tab--active {
color: var(--color-brand-primary-300, #93c5fd);
border-bottom-color: var(--color-brand-primary-300, #93c5fd);
}
.tab-panel {
// No box-styling — the panel inherits the page's surface.
// `[hidden]` is set imperatively when the tab isn't active.
}
// Charts panel container. Same surface tokens as `.filters` so the
// chart stack reads as one related block.
.charts {
margin: 0 0 1rem;
padding: 1rem;
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
:host-context(.dark) .charts {
background-color: #111827;
border-color: #1f2937;
}
.charts-heading {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
:where(.dark) & {
color: #f3f4f6;
}
}
.charts-note {
margin: 0.25rem 0 1rem;
font-size: 0.75rem;
color: #6b7280;
:where(.dark) & {
color: #9ca3af;
}
}
.charts-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (max-width: 800px) {
grid-template-columns: 1fr;
}
}
// Grid items default to `min-width: auto`, which prevents the column
// from shrinking below the intrinsic width of its content. Without
// the override, Plot's fixed-width SVG could push the column wider
// than 1fr, busting the layout and forcing a horizontal scrollbar.
.chart-tile {
min-width: 0;
}
// The stacked-bar chart carries a legend and benefits from the
// full width; flag it via a modifier and span both columns.
.chart-tile--wide {
grid-column: 1 / -1;
}
.table-wrap {
overflow-x: auto;
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
:host-context(.dark) .table-wrap {
background-color: #111827;
border-color: #1f2937;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
th,
td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
th {
font-weight: 600;
background-color: #f9fafb;
position: sticky;
top: 0;
}
}
:host-context(.dark) .audit-table {
th,
td {
border-bottom-color: #1f2937;
}
th {
background-color: #1f2937;
}
}
.cell-timestamp {
white-space: nowrap;
}
.cell-event,
.cell-trace,
.actor-hash,
.subject {
font-family: ui-monospace, monospace;
}
.cell-trace,
.actor-hash,
.subject {
word-break: break-all;
font-size: 0.75rem;
}
.actor-hash {
color: #1f2937;
}
.subject {
color: #6b7280;
margin-top: 0.125rem;
}
:host-context(.dark) .actor-hash {
color: #e5e7eb;
}
:host-context(.dark) .subject {
color: #9ca3af;
}
// The clickable variant of `.actor-hash` (rendered as a <button> so
// keyboard activation works) reuses the inline-hash typography but
// strips the native button chrome and surfaces a subtle hover
// affordance. Filter pivot per the same row's actor.
.actor-hash--clickable {
display: inline;
padding: 0;
margin: 0;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
text-decoration: underline dotted transparent;
text-underline-offset: 2px;
transition:
color 0.12s ease-out,
text-decoration-color 0.12s ease-out;
&:hover {
color: var(--color-brand-primary-600, #1d4ed8);
text-decoration-color: currentColor;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
border-radius: 0.125rem;
}
}
:host-context(.dark) .actor-hash--clickable {
&:hover {
color: var(--color-brand-primary-300, #93c5fd);
}
}
// Trace-id deep-link into Jaeger. Same hash typography as the actor
// cell (already inherited via `.cell-trace`), plus a brand-coloured
// underline so investigators read it as "follow this".
.trace-link {
color: var(--color-brand-primary-600, #1d4ed8);
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
text-decoration-thickness: 2px;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
border-radius: 0.125rem;
}
}
:host-context(.dark) .trace-link {
color: var(--color-brand-primary-300, #93c5fd);
}
.aud-badge,
.outcome-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
margin-right: 0.25rem;
}
.aud-badge {
background-color: rgba(0, 0, 0, 0.06);
color: #1f2937;
}
.outcome-badge--success {
background-color: rgba(34, 197, 94, 0.18);
color: #166534;
}
.outcome-badge--failure {
background-color: rgba(239, 68, 68, 0.18);
color: #991b1b;
}
.outcome-badge--denied {
background-color: rgba(234, 179, 8, 0.22);
color: #854d0e;
}
:host-context(.dark) {
.aud-badge {
background-color: rgba(255, 255, 255, 0.08);
color: #e5e7eb;
}
.outcome-badge--success {
background-color: rgba(34, 197, 94, 0.22);
color: #86efac;
}
.outcome-badge--failure {
background-color: rgba(239, 68, 68, 0.22);
color: #fecaca;
}
.outcome-badge--denied {
background-color: rgba(234, 179, 8, 0.22);
color: #fde68a;
}
}
.cell-payload {
details {
cursor: pointer;
}
summary {
color: var(--color-brand-primary-600, #2563eb);
font-size: 0.75rem;
}
pre {
margin: 0.25rem 0 0;
padding: 0.5rem;
background-color: rgba(0, 0, 0, 0.04);
border-radius: 0.25rem;
font-size: 0.6875rem;
font-family: ui-monospace, monospace;
word-break: break-all;
}
}
:host-context(.dark) .cell-payload {
summary {
color: var(--color-brand-primary-300, #93c5fd);
}
pre {
background-color: rgba(255, 255, 255, 0.05);
}
}
.dim {
color: #9ca3af;
}
.pagination {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 0.75rem;
}
@@ -1,408 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { AuditPage } from './audit';
import {
AuditEventsService,
type AdminAuditPage,
type AdminAuditQuery,
type AdminAuditStats,
type AdminAuditStatsQuery,
} from './audit-events.service';
const STATS: AdminAuditStats = {
dailyVolume: [
{ day: '2026-05-12', count: 5 },
{ day: '2026-05-13', count: 7 },
],
outcomeBreakdown: [
{ outcome: 'success', count: 11 },
{ outcome: 'denied', count: 1 },
],
eventTypeByDay: [
{ day: '2026-05-12', eventType: 'auth.sign_in', count: 5 },
{ day: '2026-05-13', eventType: 'auth.sign_in', count: 6 },
{ day: '2026-05-13', eventType: 'admin.access_denied', count: 1 },
],
total: 12,
};
const PAGE: AdminAuditPage = {
items: [
{
id: '11111111-1111-1111-1111-111111111111',
createdAt: '2026-05-13T10:30:00.000Z',
eventType: 'auth.sign_in',
audience: 'workforce',
actorIdHash: 'hash(jane)',
traceId: 'trace-abc',
subject: 'session:sid-1',
outcome: 'success',
payload: { amr: ['pwd', 'mfa'] },
},
{
id: '22222222-2222-2222-2222-222222222222',
createdAt: '2026-05-13T10:31:00.000Z',
eventType: 'admin.access_denied',
audience: 'workforce',
actorIdHash: 'hash(mallory)',
traceId: null,
subject: 'GET /api/admin/me',
outcome: 'denied',
payload: null,
},
],
total: 247,
limit: 50,
offset: 0,
};
interface Fixture {
fixture: ReturnType<typeof TestBed.createComponent<AuditPage>>;
query: ReturnType<typeof vi.fn>;
stats: ReturnType<typeof vi.fn>;
}
function setup(opts?: {
initial?: AdminAuditPage | 'error';
initialStats?: AdminAuditStats | 'error';
status?: number;
}): Fixture {
const initial = opts?.initial ?? PAGE;
const initialStats = opts?.initialStats ?? STATS;
const query = vi.fn().mockImplementation(() => {
if (initial === 'error') {
return throwError(() => ({ status: opts?.status ?? 500 }));
}
return of(initial);
});
const stats = vi.fn().mockImplementation(() => {
if (initialStats === 'error') {
return throwError(() => ({ status: opts?.status ?? 500 }));
}
return of(initialStats);
});
TestBed.configureTestingModule({
imports: [AuditPage],
providers: [{ provide: AuditEventsService, useValue: { query, stats } }],
});
const fixture = TestBed.createComponent(AuditPage);
return { fixture, query, stats };
}
async function flush(fixture: Fixture['fixture']): Promise<void> {
// Drain microtasks twice — the initial fetch fires from ngOnInit
// and awaits firstValueFrom (one tick) then updates the signal
// (another tick) before the DOM reflects the result.
fixture.detectChanges();
await Promise.resolve();
await Promise.resolve();
fixture.detectChanges();
await fixture.whenStable();
}
describe('AuditPage', () => {
it('runs an initial unfiltered query on init and renders the result table', async () => {
const { fixture, query } = setup();
await flush(fixture);
expect(query).toHaveBeenCalledTimes(1);
expect(query).toHaveBeenCalledWith(expect.objectContaining({ limit: 50, offset: 0 }));
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
expect(rows.length).toBe(2);
});
it('shows the result range ("12 of 247") in the status bar', async () => {
const { fixture } = setup();
await flush(fixture);
expect(
(fixture.nativeElement as HTMLElement).querySelector('.status-line')?.textContent,
).toContain('12 of 247');
});
it('renders the empty state when the page has no items', async () => {
const { fixture } = setup({
initial: { items: [], total: 0, limit: 50, offset: 0 },
});
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.empty')?.textContent).toContain('No audit events');
expect(root.querySelector('.audit-table')).toBeNull();
});
it('renders an error message when the service rejects (500)', async () => {
const { fixture } = setup({ initial: 'error', status: 500 });
await flush(fixture);
const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error');
expect(status?.textContent).toContain('Could not load the audit log');
});
it('renders a permission-aware error message on 403', async () => {
const { fixture } = setup({ initial: 'error', status: 403 });
await flush(fixture);
const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error');
expect(status?.textContent).toContain('do not have access');
});
it('passes the populated filter fields to the service on Apply', async () => {
const { fixture, query } = setup();
await flush(fixture);
const component = fixture.componentInstance;
// Simulate user input by setting the signals directly.
(component as unknown as { eventType: { set: (v: string) => void } }).eventType.set(
'auth.sign_in',
);
(component as unknown as { audience: { set: (v: string) => void } }).audience.set('workforce');
await component.search();
await flush(fixture);
expect(query).toHaveBeenCalledTimes(2);
const lastCall = query.mock.calls[1]?.[0] as AdminAuditQuery;
expect(lastCall.eventType).toBe('auth.sign_in');
expect(lastCall.audience).toBe('workforce');
expect(lastCall.offset).toBe(0);
});
it('resets the offset to 0 on each Apply (no stale page when filters narrow)', async () => {
const { fixture, query } = setup();
await flush(fixture);
const component = fixture.componentInstance;
// Advance to next page.
await component.next();
await flush(fixture);
expect((query.mock.calls[1]?.[0] as AdminAuditQuery).offset).toBe(50);
// Apply a new filter — offset must drop back to 0.
await component.search();
await flush(fixture);
expect((query.mock.calls[2]?.[0] as AdminAuditQuery).offset).toBe(0);
});
it('clearFilters empties every filter signal and re-queries from offset 0', async () => {
const { fixture, query } = setup();
await flush(fixture);
const component = fixture.componentInstance;
(component as unknown as { eventType: { set: (v: string) => void } }).eventType.set('x');
(component as unknown as { outcome: { set: (v: string) => void } }).outcome.set('denied');
await component.clearFilters();
await flush(fixture);
const lastCall = query.mock.calls[1]?.[0] as AdminAuditQuery;
expect(lastCall.eventType).toBeUndefined();
expect(lastCall.outcome).toBeUndefined();
expect(lastCall.offset).toBe(0);
});
it('Next is disabled when the loaded page covers the total', async () => {
const { fixture } = setup({
initial: { items: PAGE.items, total: 2, limit: 50, offset: 0 },
});
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const nextBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('.pagination .btn')).find(
(b) => b.textContent?.trim() === 'Next',
);
expect(nextBtn?.disabled).toBe(true);
});
it('Previous is disabled at offset 0', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const prevBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('.pagination .btn')).find(
(b) => b.textContent?.trim() === 'Previous',
);
expect(prevBtn?.disabled).toBe(true);
});
it('renders the outcome badge variant matching the row', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
expect(rows[0]?.querySelector('.outcome-badge--success')).not.toBeNull();
expect(rows[1]?.querySelector('.outcome-badge--denied')).not.toBeNull();
});
it('shows the payload disclosure only for rows that have a payload', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
expect(rows[0]?.querySelector('details')).not.toBeNull();
expect(rows[1]?.querySelector('details')).toBeNull();
});
it('renders trace_id as a Jaeger deep link (anchor with target=_blank)', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
const link = rows[0]?.querySelector<HTMLAnchorElement>('.trace-link');
expect(link).not.toBeNull();
expect(link?.getAttribute('href')).toContain('/trace/trace-abc');
expect(link?.getAttribute('target')).toBe('_blank');
expect(link?.getAttribute('rel')).toContain('noopener');
});
it('renders the dash placeholder for rows without a trace_id', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
expect(rows[1]?.querySelector('.trace-link')).toBeNull();
expect(rows[1]?.querySelector('.cell-trace')?.textContent?.trim()).toBe('—');
});
it('clicking an actor hash re-runs the query filtered on that hash + resets offset', async () => {
const { fixture, query } = setup();
await flush(fixture);
query.mockClear();
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
rows[0]?.querySelector<HTMLButtonElement>('.actor-hash--clickable')?.click();
await flush(fixture);
expect(query).toHaveBeenCalledTimes(1);
const filters = query.mock.calls[0]?.[0] as AdminAuditQuery;
expect(filters.actorIdHash).toBe('hash(jane)');
expect(filters.offset).toBe(0);
});
it('renders the anonymous actor as plain text (not a button)', async () => {
const anonymousPage: AdminAuditPage = {
...PAGE,
items: [{ ...PAGE.items[0]!, actorIdHash: null }],
};
const { fixture } = setup({ initial: anonymousPage });
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const row = root.querySelector<HTMLTableRowElement>('.audit-table tbody tr');
expect(row?.querySelector('.actor-hash--clickable')).toBeNull();
expect(row?.querySelector('.actor-hash')?.textContent?.trim()).toBe('(anonymous)');
});
describe('tabs + charts', () => {
it('defaults to the Table tab on first render — does NOT call the stats endpoint', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const activeTab = root.querySelector('.tab--active');
expect(activeTab?.textContent?.trim()).toBe('Table');
expect(stats).not.toHaveBeenCalled();
});
it('fetches stats when the user opens the Charts tab for the first time', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const chartsTab = Array.from(root.querySelectorAll<HTMLButtonElement>('.tab')).find(
(b) => b.textContent?.trim() === 'Charts',
);
chartsTab?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
expect(root.querySelector('lib-bar-chart')).not.toBeNull();
expect(root.querySelector('lib-donut-chart')).not.toBeNull();
expect(root.querySelector('lib-stacked-bar-chart')).not.toBeNull();
});
it('does NOT pass pagination knobs to the stats endpoint', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
const filters = stats.mock.calls[0]?.[0] as AdminAuditStatsQuery & {
limit?: number;
offset?: number;
};
expect(filters.limit).toBeUndefined();
expect(filters.offset).toBeUndefined();
});
it('does NOT re-fetch stats on pagination (filters unchanged)', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
// Switch back to Table and paginate.
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Table')
?.click();
await flush(fixture);
// Open Charts again — cache still valid, no new stats call.
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
});
it('re-fetches stats when filters change and the Charts tab is active', async () => {
const { fixture, stats } = setup();
await flush(fixture);
// Open Charts to load the initial stats.
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
// Component-state mutation: tweak a filter and re-search.
const cmp = fixture.componentInstance as unknown as {
eventType: { set(v: string): void };
search(): Promise<void>;
};
cmp.eventType.set('auth.sign_in');
await cmp.search();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(2);
});
it('shows the donut centre label with the server-side total (not the per-page count)', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
const donutCenter = root.querySelector('lib-donut-chart .donut-center-label');
expect(donutCenter?.textContent?.trim()).toBe(String(STATS.total));
});
it('shows the empty-state message when the stats endpoint returns 0 events', async () => {
const emptyStats: AdminAuditStats = {
dailyVolume: [],
outcomeBreakdown: [],
eventTypeByDay: [],
total: 0,
};
const { fixture } = setup({ initialStats: emptyStats });
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(root.querySelector('lib-bar-chart')).toBeNull();
expect(root.querySelector('#panel-charts .empty')).not.toBeNull();
});
it('renders a permission-aware error when the stats endpoint returns 403', async () => {
const { fixture } = setup({ initialStats: 'error', status: 403 });
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
const errMsg = root.querySelector('.status-line--error')?.textContent?.trim();
expect(errMsg).toContain('do not have access');
});
});
});
@@ -1,335 +0,0 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { BarChart, DonutChart, StackedBarChart, semanticStatusColors } from 'shared-charts';
import { environment } from '../../../environments/environment';
import {
AuditEventsService,
type AdminAuditPage,
type AdminAuditQuery,
type AdminAuditStats,
type AdminAuditStatsQuery,
} from './audit-events.service';
/** Tabs shown above the audit content. */
type AuditTab = 'table' | 'charts';
/** Page-size options the SPA offers. Matches the BFF's `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (200). */
const PAGE_SIZES = [25, 50, 100, 200] as const;
/**
* Audit log viewer per ADR-0020's v1 admin catalogue. Consumes
* `GET /api/admin/audit` from the BFF (PR #132) and renders:
*
* - A filter row binding to the same fields the BFF accepts
* (event type, audience, outcome, subject prefix, time range).
* - A page-size selector + previous / next buttons. Pagination is
* offset-based to match the BFF's v1 implementation.
* - A table of results with the timestamp (locale-formatted),
* event type, audience+outcome cell, actor hash + subject, and
* trace id. Payload is rendered as JSON in a details disclosure
* so the table stays scannable but the structured detail is one
* click away.
*
* The page emits its own `admin.audit.query` audit row server-side on
* every fetch — the BFF deterrent against fishing expeditions is on
* the read endpoint itself, not in the SPA.
*/
@Component({
selector: 'app-audit-page',
imports: [FormsModule, BarChart, DonutChart, StackedBarChart],
templateUrl: './audit.html',
styleUrl: './audit.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AuditPage {
private readonly service = inject(AuditEventsService);
protected readonly pageSizes = PAGE_SIZES;
/**
* Outcome → fill mapping for the donut chart. Semantic colours
* resolved from the shared-charts curated set: success = green,
* denied = orange (`warning` intent), failure = red. Keeps the
* legend "obvious without reading" — admins eyeballing the donut
* see the green slice and know it's success without parsing the
* tooltip.
*/
protected readonly outcomeColorMap: Readonly<Record<string, string>> = {
success: semanticStatusColors.success,
failure: semanticStatusColors.error,
denied: semanticStatusColors.warning,
};
// Filter form state. Each field is a separate signal so a change
// to one doesn't churn the others through ngModel's reference
// equality. All start empty — the BFF returns the most recent
// events on an unfiltered query.
protected readonly eventType = signal('');
protected readonly actorIdHash = signal('');
protected readonly audience = signal<'' | 'workforce' | 'customer'>('');
protected readonly outcome = signal<'' | 'success' | 'failure' | 'denied'>('');
protected readonly subjectPrefix = signal('');
protected readonly createdAtFrom = signal('');
protected readonly createdAtTo = signal('');
protected readonly limit = signal<number>(50);
protected readonly offset = signal<number>(0);
// Result state. `page` is the current server response; `loading`
// is true while a fetch is in flight; `error` carries a short
// string for the alert region.
protected readonly page = signal<AdminAuditPage | null>(null);
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
/** True when the loaded page does NOT cover the next offset boundary. */
protected readonly hasNextPage = computed(() => {
const p = this.page();
if (p === null) return false;
return p.offset + p.items.length < p.total;
});
protected readonly hasPreviousPage = computed(() => {
const p = this.page();
if (p === null) return false;
return p.offset > 0;
});
// Human-readable "results 150 of 1 234" string. Defensive — when
// the page is empty we report "no results" rather than "10".
protected readonly resultRange = computed(() => {
const p = this.page();
if (p === null) return '';
if (p.items.length === 0) return p.total === 0 ? 'No results' : `0 of ${p.total}`;
const first = p.offset + 1;
const last = p.offset + p.items.length;
return `${first}${last} of ${p.total}`;
});
// ---------------------------------------------------------------
// Tabs + server-side stats — Charts tab consumes
// `GET /api/admin/audit/stats` (full filtered set, Redis-cached
// 5 min per filter-hash per ADR-0013 §"Reader endpoints"). Fetch
// is lazy: only fires when the Charts tab is opened, then again
// when filters change with the Charts tab active.
// ---------------------------------------------------------------
protected readonly tab = signal<AuditTab>('table');
protected readonly stats = signal<AdminAuditStats | null>(null);
protected readonly statsLoading = signal(false);
protected readonly statsError = signal<string | null>(null);
/** True when the loaded stats are non-empty (total > 0). */
protected readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0);
/**
* Run a new query with the current filter signals at offset 0.
* Called by the "Apply filters" button + the initial render via
* the template's `@defer (on viewport)` block.
*
* When the user applies new filters, the loaded `stats` becomes
* stale — clear it so the next visit to the Charts tab refetches.
* If the Charts tab is currently active, refetch immediately;
* otherwise wait for the user to open it.
*/
async search(): Promise<void> {
this.offset.set(0);
this.stats.set(null);
const tasks: Array<Promise<void>> = [this.fetch()];
if (this.tab() === 'charts') {
tasks.push(this.fetchStats());
}
await Promise.all(tasks);
}
/**
* Switch to a tab. On the first switch to `charts` (or any switch
* that follows a filter change), fire the stats fetch — pagination
* within the table doesn't invalidate stats (the filtered set is
* unchanged), so re-opening Charts after paginating is free.
*/
async setTab(tab: AuditTab): Promise<void> {
this.tab.set(tab);
if (tab === 'charts' && this.stats() === null && !this.statsLoading()) {
await this.fetchStats();
}
}
/** Page navigation — moves the offset by `limit`, then fetches. */
async next(): Promise<void> {
if (!this.hasNextPage()) return;
this.offset.update((o) => o + this.limit());
await this.fetch();
}
async previous(): Promise<void> {
if (!this.hasPreviousPage()) return;
this.offset.update((o) => Math.max(0, o - this.limit()));
await this.fetch();
}
/** Reset every filter to its empty value and re-query. */
async clearFilters(): Promise<void> {
this.eventType.set('');
this.actorIdHash.set('');
this.audience.set('');
this.outcome.set('');
this.subjectPrefix.set('');
this.createdAtFrom.set('');
this.createdAtTo.set('');
this.offset.set(0);
this.stats.set(null);
const tasks: Array<Promise<void>> = [this.fetch()];
if (this.tab() === 'charts') {
tasks.push(this.fetchStats());
}
await Promise.all(tasks);
}
/** Format an ISO timestamp into the user's locale for the table. */
protected formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
// Fall back to the raw ISO string if Date refuses it — better
// than rendering "Invalid Date" in the table.
return iso;
}
}
/** Stringify the JSONB payload for the disclosure widget. */
protected formatPayload(payload: Record<string, unknown> | null): string {
if (payload === null) return '—';
try {
return JSON.stringify(payload, null, 2);
} catch {
return String(payload);
}
}
/**
* Build a Jaeger-UI deep link for the given trace id — the audit
* table's `trace_id` cell becomes a clickable link to the
* trace-explorer per ADR-0012's "join audit and app logs by
* trace_id" promise. Anonymous events (`traceId === null`) don't
* get a link; the cell stays a dash.
*/
protected jaegerUrl(traceId: string): string {
return `${environment.jaegerBaseUrl}/trace/${encodeURIComponent(traceId)}`;
}
/**
* Pivot the table on a single actor: set the `actorIdHash` filter
* to the clicked hash, reset paging to 0, and re-query. Lets an
* investigator click any row's actor cell to "show me everything
* else this actor did". Per-query audit row (`admin.audit.query`)
* is still emitted server-side so the pivot is itself auditable.
*/
async filterByActor(hash: string): Promise<void> {
if (!hash) return;
this.actorIdHash.set(hash);
this.offset.set(0);
this.stats.set(null);
const tasks: Array<Promise<void>> = [this.fetch()];
if (this.tab() === 'charts') {
tasks.push(this.fetchStats());
}
await Promise.all(tasks);
}
private async fetch(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const filters = this.buildFilters();
const page = await firstValueFrom(this.service.query(filters));
this.page.set(page);
} catch (err) {
// Don't surface the raw error message to the admin — it's
// either a 401 (handled by the unauthorized interceptor), a
// 403 (admin role revoked mid-session), or a 5xx. A short
// generic string keeps the UI predictable; the structured
// error envelope is in the network tab for ops.
const status = errorStatus(err);
this.error.set(
status === 403
? 'You do not have access to the audit log on this session.'
: 'Could not load the audit log. Try again in a moment.',
);
this.page.set(null);
} finally {
this.loading.set(false);
}
}
private buildFilters(): AdminAuditQuery {
return {
eventType: this.eventType().trim() || undefined,
actorIdHash: this.actorIdHash().trim() || undefined,
audience: this.audience() || undefined,
outcome: this.outcome() || undefined,
subjectPrefix: this.subjectPrefix().trim() || undefined,
createdAtFrom: this.createdAtFrom() ? toIso(this.createdAtFrom()) : undefined,
createdAtTo: this.createdAtTo() ? toIso(this.createdAtTo()) : undefined,
limit: this.limit(),
offset: this.offset(),
};
}
/**
* Stats endpoint call. Shares the filter shape with `fetch` minus
* pagination — `buildStatsFilters` strips `limit/offset`.
*/
private async fetchStats(): Promise<void> {
this.statsLoading.set(true);
this.statsError.set(null);
try {
const filters = this.buildStatsFilters();
const stats = await firstValueFrom(this.service.stats(filters));
this.stats.set(stats);
} catch (err) {
const status = errorStatus(err);
this.statsError.set(
status === 403
? 'You do not have access to the audit log on this session.'
: 'Could not load audit statistics. Try again in a moment.',
);
this.stats.set(null);
} finally {
this.statsLoading.set(false);
}
}
private buildStatsFilters(): AdminAuditStatsQuery {
const { limit: _limit, offset: _offset, ...rest } = this.buildFilters();
void _limit;
void _offset;
return rest;
}
ngOnInit(): void {
// Fire the initial unfiltered query so the table renders with
// the most recent events on first paint. Fire-and-forget — the
// page handles its own loading / error state internally.
void this.fetch();
}
}
function toIso(localValue: string): string {
// `<input type="datetime-local">` emits `YYYY-MM-DDTHH:mm` (no
// timezone). Treat that as the user's local time and convert to
// a true ISO 8601 string so the BFF's IsISO8601 validator accepts
// it. Falls back to the raw value if Date can't parse it (the
// BFF will then return a 400 with a clear validation error).
const d = new Date(localValue);
return Number.isNaN(d.getTime()) ? localValue : d.toISOString();
}
function errorStatus(err: unknown): number | null {
if (typeof err === 'object' && err !== null && 'status' in err) {
const s = (err as { status: unknown }).status;
return typeof s === 'number' ? s : null;
}
return null;
}
@@ -1,41 +0,0 @@
<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>
<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>
@@ -1,153 +0,0 @@
.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;
}
}
@@ -1,86 +0,0 @@
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,31 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { AuthService } from 'feature-auth';
/**
* 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".
*
* 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 {
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();
}
}
@@ -1,48 +0,0 @@
@if (user(); as currentUser) {
<section class="profile">
<header class="profile-header">
<h1 class="title">My profile</h1>
<p class="intro">
Identity served by the BFF from the active admin session. Read-only — role assignments are
managed in the Entra Admin Center.
</p>
</header>
<article class="card" aria-labelledby="identity-heading">
<h2 id="identity-heading" class="card-title">Identity</h2>
<dl class="grid">
<div>
<dt>Display name</dt>
<dd data-testid="profile-displayname">{{ currentUser.displayName }}</dd>
</div>
<div>
<dt>Username</dt>
<dd data-testid="profile-username">{{ currentUser.username }}</dd>
</div>
<div>
<dt>Entra object id</dt>
<dd class="mono" data-testid="profile-oid">{{ currentUser.oid }}</dd>
</div>
<div>
<dt>Tenant id</dt>
<dd class="mono" data-testid="profile-tid">{{ currentUser.tid }}</dd>
</div>
</dl>
</article>
@if (currentUser.roles && currentUser.roles.length > 0) {
<article class="card" aria-labelledby="roles-heading">
<h2 id="roles-heading" class="card-title">App roles</h2>
<p class="card-intro">
Roles assigned on the BFF's Entra app registration. Drives access to
<code>/api/admin/*</code> through <code>&#64;RequireAdmin</code>.
</p>
<ul class="roles" data-testid="profile-roles">
@for (role of currentUser.roles; track role) {
<li class="role-chip">{{ role }}</li>
}
</ul>
</article>
}
</section>
}
@@ -1,150 +0,0 @@
.profile {
max-width: 720px;
margin: 0 auto;
}
.profile-header {
margin-bottom: 1.5rem;
}
.title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.intro {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
}
:host-context(.dark) .intro {
color: #9ca3af;
}
.card {
padding: 1rem 1.25rem;
margin-bottom: 1rem;
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
:host-context(.dark) .card {
background-color: #111827;
border-color: #1f2937;
}
.card-title {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
}
:host-context(.dark) .card-title {
color: #9ca3af;
}
.card-intro {
margin: 0 0 0.75rem;
font-size: 0.875rem;
color: #4b5563;
code {
padding: 0.0625rem 0.25rem;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.05);
font-family: ui-monospace, monospace;
font-size: 0.875em;
}
}
:host-context(.dark) .card-intro {
color: #d1d5db;
code {
background-color: rgba(255, 255, 255, 0.08);
}
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.875rem 1.25rem;
margin: 0;
@media (max-width: 540px) {
grid-template-columns: 1fr;
}
> div {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
dt {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
font-weight: 500;
}
dd {
margin: 0;
font-size: 0.875rem;
color: #111827;
}
dd.mono {
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: #374151;
word-break: break-all;
}
}
:host-context(.dark) .grid {
dt {
color: #9ca3af;
}
dd {
color: #f3f4f6;
}
dd.mono {
color: #d1d5db;
}
}
.roles {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.role-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 999px;
background-color: rgba(29, 78, 216, 0.1);
color: var(--color-brand-primary-700, #1e40af);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
}
:host-context(.dark) .role-chip {
background-color: rgba(96, 165, 250, 0.16);
color: #bfdbfe;
}
@@ -1,86 +0,0 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import {
AUTH_BFF_BASE_URL,
AUTH_NAVIGATOR,
AUTH_PATH_PREFIX,
type CurrentUser,
} from 'feature-auth';
import { ProfilePage } from './profile';
const BFF_BASE = 'http://bff.test/api';
const ADMIN_ME_URL = `${BFF_BASE}/admin/auth/me`;
const USER: CurrentUser = {
oid: 'admin-oid-abc',
tid: 'tenant-1',
username: 'admin@apf.example',
displayName: 'Admin Smith',
roles: ['Portal.Admin'],
};
async function setup(user: CurrentUser | null) {
TestBed.configureTestingModule({
imports: [ProfilePage],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
{ provide: AUTH_NAVIGATOR, useValue: vi.fn() },
],
});
const fixture = TestBed.createComponent(ProfilePage);
const http = TestBed.inject(HttpTestingController);
const req = http.expectOne(ADMIN_ME_URL);
if (user) {
req.flush(user);
} else {
req.flush({}, { status: 401, statusText: 'Unauthorized' });
}
await Promise.resolve();
fixture.detectChanges();
await fixture.whenStable();
return { fixture, http };
}
describe('ProfilePage (portal-admin)', () => {
beforeEach(() => TestBed.resetTestingModule());
it('renders the identity card with displayName, username, oid, tid', async () => {
const { fixture } = await setup(USER);
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('[data-testid="profile-displayname"]')?.textContent?.trim()).toBe(
'Admin Smith',
);
expect(root.querySelector('[data-testid="profile-username"]')?.textContent?.trim()).toBe(
'admin@apf.example',
);
expect(root.querySelector('[data-testid="profile-oid"]')?.textContent?.trim()).toBe(
'admin-oid-abc',
);
expect(root.querySelector('[data-testid="profile-tid"]')?.textContent?.trim()).toBe('tenant-1');
});
it('renders the App roles card with one chip per role when present', async () => {
const { fixture } = await setup({ ...USER, roles: ['Portal.Admin', 'Portal.Auditor'] });
const root = fixture.nativeElement as HTMLElement;
const chips = Array.from(root.querySelectorAll('[data-testid="profile-roles"] .role-chip'));
expect(chips.map((el) => el.textContent?.trim())).toEqual(['Portal.Admin', 'Portal.Auditor']);
});
it('omits the App roles card entirely when the session carries no roles', async () => {
const { fixture } = await setup({ ...USER, roles: [] });
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('[data-testid="profile-roles"]')).toBeNull();
});
it('renders nothing when the user is anonymous (template @if guard)', async () => {
const { fixture } = await setup(null);
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.profile')).toBeNull();
});
});
@@ -1,25 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { AuthService } from 'feature-auth';
/**
* Admin-portal profile page. Lazy-loaded at `/profile`, gated by
* `authGuard` so `AuthService.currentUser()` is guaranteed non-null
* by the time the template runs.
*
* Carries more identity surface than the `portal-shell` counterpart
* because the admin-side `/api/admin/auth/me` includes the `roles`
* claim (admin SPA renders role badges, drives admin-only UI). The
* user-portal page deliberately skips `roles` to stay aligned with
* ADR-0009 §"curated public view" — see `CurrentUser` for the field
* shape.
*/
@Component({
selector: 'app-profile-page',
templateUrl: './profile.html',
styleUrl: './profile.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProfilePage {
private readonly auth = inject(AuthService);
protected readonly user = this.auth.currentUser;
}
@@ -1,126 +0,0 @@
<section class="user-scopes">
<header class="user-scopes-header">
<p class="crumbs">
<a routerLink="/users">← Back to users</a>
</p>
<h1 class="title">User scopes</h1>
@if (page(); as p) {
<p class="intro">
Managing scopes for <strong>{{ p.user.displayName }}</strong> ({{ p.user.email ?? '—' }},
<code>{{ p.user.oid }}</code>). Every grant emits <code>admin.scope_granted</code> and every
revoke emits <code>admin.scope_revoked</code> — scope-management is itself auditable per
ADR-0013.
</p>
}
</header>
<div class="status-bar" role="status" aria-live="polite">
@if (loading()) {
<span class="status-line">Loading…</span>
} @else if (error(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
}
</div>
@if (page(); as p) {
<section class="scopes-section" aria-labelledby="current-scopes-h">
<h2 id="current-scopes-h" class="section-heading">Current scopes</h2>
@if (p.scopes.length === 0) {
<p class="empty">No scopes granted to this user yet.</p>
} @else {
<div class="table-wrap">
<table class="scopes-table">
<thead>
<tr>
<th scope="col">Scope</th>
<th scope="col">Source</th>
<th scope="col">Granted</th>
<th scope="col">Expires</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (scope of p.scopes; track scope.id) {
<tr>
<td class="cell-scope">
<code>{{ formatScope(scope.kind, scope.value) }}</code>
</td>
<td class="cell-source">{{ scope.source }}</td>
<td class="cell-timestamp">{{ formatTimestamp(scope.createdAt) }}</td>
<td class="cell-timestamp">{{ formatTimestamp(scope.expiresAt) }}</td>
<td class="cell-actions">
<button
type="button"
class="btn btn--danger"
(click)="revoke(scope.id, formatScope(scope.kind, scope.value))"
>
Revoke
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="grant-section" aria-labelledby="grant-h">
<h2 id="grant-h" class="section-heading">Grant a new scope</h2>
<form class="grant-form" (submit)="$event.preventDefault(); grant()">
<div class="grant-grid">
<label class="field">
<span class="field-label">Kind</span>
<select
name="kind"
[ngModel]="newKind()"
(ngModelChange)="newKind.set($event)"
[disabled]="submitting()"
>
@for (kind of scopeKinds; track kind) {
<option [value]="kind">{{ kind }}</option>
}
</select>
</label>
<label class="field" [class.field--hidden]="!needsValue()">
<span class="field-label">Value</span>
<input
type="text"
name="value"
[ngModel]="newValue()"
(ngModelChange)="newValue.set($event)"
[disabled]="submitting() || !needsValue()"
placeholder="0330800013 / 33 / 75"
[attr.aria-required]="needsValue() ? 'true' : 'false'"
[attr.aria-describedby]="needsValue() ? 'value-hint' : null"
/>
@if (needsValue()) {
<span id="value-hint" class="field-hint">
For etablissement: Structure.code. For delegation: dept code (33, 2A). For region: INSEE
region code (75).
</span>
}
</label>
<label class="field">
<span class="field-label">Expires at (optional)</span>
<input
type="datetime-local"
name="expiresAt"
[ngModel]="newExpiresAt()"
(ngModelChange)="newExpiresAt.set($event)"
[disabled]="submitting()"
/>
</label>
</div>
@if (submitError(); as msg) {
<p class="submit-error" role="alert">{{ msg }}</p>
}
<div class="grant-actions">
<button type="submit" class="btn btn--primary" [disabled]="submitting()">
@if (submitting()) { Granting… } @else { Grant }
</button>
</div>
</form>
</section>
}
</section>
@@ -1,152 +0,0 @@
.user-scopes {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.user-scopes-header {
.crumbs {
margin: 0 0 0.5rem;
font-size: 0.875rem;
}
.title {
margin: 0 0 0.5rem;
}
.intro {
margin: 0;
color: var(--color-text-secondary, #555);
}
}
.status-bar {
min-height: 1.5rem;
}
.status-line--error {
color: var(--color-danger, #b00020);
}
.section-heading {
margin: 0 0 0.75rem;
font-size: 1.125rem;
}
.empty {
font-style: italic;
color: var(--color-text-secondary, #555);
}
.table-wrap {
overflow-x: auto;
}
.scopes-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--color-border, #ddd);
}
.cell-scope code {
font-family: var(--font-mono, monospace);
}
.cell-actions {
text-align: right;
}
}
.grant-section {
background: var(--color-bg-surface, #fafafa);
padding: 1rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.5rem;
}
.grant-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
margin-bottom: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
&.field--hidden {
visibility: hidden;
}
.field-label {
font-weight: 600;
font-size: 0.875rem;
}
.field-hint {
font-size: 0.75rem;
color: var(--color-text-secondary, #555);
}
input,
select {
padding: 0.5rem;
border: 1px solid var(--color-border, #ccc);
border-radius: 0.25rem;
min-height: 44px; /* WCAG 2.2 AA touch target (2.5.5 / 2.5.8). */
}
}
.submit-error {
color: var(--color-danger, #b00020);
margin: 0 0 0.75rem;
}
.grant-actions {
display: flex;
justify-content: flex-end;
}
.btn {
min-height: 44px;
min-width: 44px;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn--primary {
background: var(--color-brand-accent-500, #de9415);
color: white;
}
.btn--danger {
background: transparent;
color: var(--color-danger, #b00020);
border-color: currentColor;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@@ -1,92 +0,0 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import { UserScopesService, type UserScope, type UserScopesPayload } from './user-scopes.service';
const BFF_BASE = 'http://bff.test/api';
const SCOPE: UserScope = {
id: 'scope-1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
};
const PAGE: UserScopesPayload = {
user: {
oid: 'target-oid',
userId: 'user-uuid-1',
displayName: 'Jane Doe',
email: 'jane@apf.example',
},
scopes: [SCOPE],
};
function setup() {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
],
});
return {
service: TestBed.inject(UserScopesService),
http: TestBed.inject(HttpTestingController),
};
}
describe('UserScopesService.list', () => {
it('GETs /admin/users/:oid/scopes and returns the page', async () => {
const { service, http } = setup();
const promise = firstValueFrom(service.list('target-oid'));
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
expect(req.request.method).toBe('GET');
req.flush(PAGE);
await expect(promise).resolves.toEqual(PAGE);
});
it('URL-encodes the oid', async () => {
const { service, http } = setup();
void firstValueFrom(service.list('weird/oid'));
const req = http.expectOne(`${BFF_BASE}/admin/users/weird%2Foid/scopes`);
req.flush(PAGE);
});
});
describe('UserScopesService.grant', () => {
it('POSTs the input body and returns the created scope', async () => {
const { service, http } = setup();
const promise = firstValueFrom(
service.grant('target-oid', {
kind: 'etablissement',
value: '0330800013',
expiresAt: '2026-12-31T23:59:59.000Z',
}),
);
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
kind: 'etablissement',
value: '0330800013',
expiresAt: '2026-12-31T23:59:59.000Z',
});
req.flush(SCOPE);
await expect(promise).resolves.toEqual(SCOPE);
});
});
describe('UserScopesService.revoke', () => {
it('DELETEs the scope by id', async () => {
const { service, http } = setup();
const promise = firstValueFrom(service.revoke('target-oid', 'scope-1'));
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes/scope-1`);
expect(req.request.method).toBe('DELETE');
req.flush(null);
await expect(promise).resolves.toBeNull();
});
});
@@ -1,65 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import type { Observable } from 'rxjs';
/**
* One scope row as the SPA sees it — mirror of `UserScopeDto` on
* the BFF side. ISO-string timestamps; the SPA never round-trips
* `Date` instances per the audit-page convention.
*/
export interface UserScope {
readonly id: string;
readonly kind: string;
readonly value: string;
readonly source: string;
readonly createdAt: string;
readonly expiresAt: string | null;
}
export interface UserScopesPayload {
readonly user: {
readonly oid: string;
readonly userId: string;
readonly displayName: string;
readonly email: string | null;
};
readonly scopes: ReadonlyArray<UserScope>;
}
export interface GrantUserScopeInput {
readonly kind: string;
readonly value?: string;
readonly expiresAt?: string;
}
/**
* `UserScopesService` — Thin HttpClient wrapper around
* `/api/admin/users/:oid/scopes`. Same shape convention as
* `AdminUsersService` — `providedIn: 'root'` because the single
* consumer is the `UserScopesPayload`.
*/
@Injectable({ providedIn: 'root' })
export class UserScopesService {
private readonly http = inject(HttpClient);
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
list(oid: string): Observable<UserScopesPayload> {
return this.http.get<UserScopesPayload>(
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
);
}
grant(oid: string, input: GrantUserScopeInput): Observable<UserScope> {
return this.http.post<UserScope>(
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
input,
);
}
revoke(oid: string, scopeId: string): Observable<void> {
return this.http.delete<void>(
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes/${encodeURIComponent(scopeId)}`,
);
}
}
@@ -1,134 +0,0 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import {
UserScopesService,
type GrantUserScopeInput,
type UserScope,
type UserScopesPayload,
} from './user-scopes.service';
import { UserScopesPage } from './user-scopes';
const SCOPE: UserScope = {
id: 'scope-1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
};
const PAGE: UserScopesPayload = {
user: {
oid: 'target-oid',
userId: 'user-uuid-1',
displayName: 'Jane Doe',
email: 'jane@apf.example',
},
scopes: [SCOPE],
};
function setup(opts?: {
list?: ReturnType<typeof vi.fn>;
grant?: ReturnType<typeof vi.fn>;
revoke?: ReturnType<typeof vi.fn>;
}) {
const list = opts?.list ?? vi.fn().mockReturnValue(of(PAGE));
const grant = opts?.grant ?? vi.fn().mockReturnValue(of(SCOPE));
const revoke = opts?.revoke ?? vi.fn().mockReturnValue(of(undefined));
TestBed.configureTestingModule({
imports: [UserScopesPage],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: AUTH_BFF_BASE_URL, useValue: 'http://bff.test/api' },
{
provide: UserScopesService,
useValue: { list, grant, revoke },
},
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: new Map([['oid', 'target-oid']]) } },
},
],
});
const fixture = TestBed.createComponent(UserScopesPage);
return { fixture, list, grant, revoke };
}
describe('UserScopesPage', () => {
it('loads the scopes for the route param oid on init', async () => {
const { fixture, list } = setup();
fixture.detectChanges();
await fixture.whenStable();
expect(list).toHaveBeenCalledWith('target-oid');
});
it('surfaces a 404 with the seed/sign-in hint', async () => {
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 404 })));
const { fixture } = setup({ list });
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const html = (fixture.nativeElement as HTMLElement).innerHTML;
expect(html).toMatch(/User not found/);
});
it('forbids a 403 with a friendly error', async () => {
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 403 })));
const { fixture } = setup({ list });
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const html = (fixture.nativeElement as HTMLElement).innerHTML;
expect(html).toMatch(/admin access/);
});
});
describe('UserScopesPage.grant', () => {
it('sends value for value-bearing kinds + refreshes the list on success', async () => {
let grantPayload: GrantUserScopeInput | null = null;
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
grantPayload = payload;
return of(SCOPE);
});
const list = vi.fn().mockReturnValue(of(PAGE));
const { fixture } = setup({ list, grant });
fixture.detectChanges();
await fixture.whenStable();
const component = fixture.componentInstance as unknown as {
newKind: { set: (v: string) => void };
newValue: { set: (v: string) => void };
grant: () => Promise<void>;
};
component.newKind.set('etablissement');
component.newValue.set('0330800013');
await component.grant();
expect(grant).toHaveBeenCalledTimes(1);
expect(grantPayload).toEqual({ kind: 'etablissement', value: '0330800013' });
// List re-fetched after the grant.
expect(list).toHaveBeenCalledTimes(2);
});
it('omits value for valueless kinds', async () => {
let grantPayload: GrantUserScopeInput | null = null;
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
grantPayload = payload;
return of(SCOPE);
});
const { fixture } = setup({ grant });
fixture.detectChanges();
await fixture.whenStable();
const component = fixture.componentInstance as unknown as {
newKind: { set: (v: string) => void };
grant: () => Promise<void>;
};
component.newKind.set('unrestricted');
await component.grant();
expect(grantPayload).toEqual({ kind: 'unrestricted' });
});
});
@@ -1,180 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
type OnInit,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
UserScopesService,
type GrantUserScopeInput,
type UserScopesPayload,
} from './user-scopes.service';
/**
* The six scope kinds from ADR-0025's catalogue. Hardcoded here so
* the SPA can dropdown them without importing from `shared-auth`
* (which would couple the admin app to the BFF lib path). Drift gate
* lives BFF-side; if the catalogue grows, this list grows in lockstep.
*/
const SCOPE_KINDS = [
'self',
'etablissement',
'delegation',
'region',
'siege',
'unrestricted',
] as const;
const VALUE_BEARING = new Set<string>(['etablissement', 'delegation', 'region']);
/**
* Admin scope-management screen per [ADR-0026 PR 2b](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0026-person-user-portal-data-model.md).
* Lists every UserScope row for the target user, lets the admin grant
* a new one (kind dropdown + value + optional expiresAt), and revoke
* existing ones inline.
*
* Reaches the BFF at `/api/admin/users/:oid/scopes`. The `:oid` URL
* param is the affected user's Entra `oid` — same identifier the
* `/admin/users` list page uses, so a "Manage scopes" link from that
* list lands here without a lookup.
*
* A11y posture (per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)):
* - Form labels associated via `<label>` wrapping or `for`.
* - Live region for status/error messages (`role="status"`).
* - Touch targets respect 44×44 min (button padding in the SCSS).
* - Revoke buttons confirm via `window.confirm` to avoid accidental
* destructive action on a row's keyboard activation.
*/
@Component({
selector: 'app-user-scopes-page',
imports: [FormsModule, RouterLink],
templateUrl: './user-scopes.html',
styleUrl: './user-scopes.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserScopesPage implements OnInit {
private readonly service = inject(UserScopesService);
private readonly route = inject(ActivatedRoute);
protected readonly scopeKinds = SCOPE_KINDS;
protected readonly oid = signal('');
protected readonly page = signal<UserScopesPayload | null>(null);
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
// Form state for the "grant new scope" row.
protected readonly newKind = signal<string>('self');
protected readonly newValue = signal('');
protected readonly newExpiresAt = signal('');
protected readonly submitting = signal(false);
protected readonly submitError = signal<string | null>(null);
protected readonly needsValue = computed(() => VALUE_BEARING.has(this.newKind()));
ngOnInit(): void {
const oid = this.route.snapshot.paramMap.get('oid') ?? '';
this.oid.set(oid);
void this.fetch();
}
protected async fetch(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const page = await firstValueFrom(this.service.list(this.oid()));
this.page.set(page);
} catch (err) {
this.error.set(this.translateError(err, 'Could not load scopes for this user.'));
this.page.set(null);
} finally {
this.loading.set(false);
}
}
protected async grant(): Promise<void> {
if (this.submitting()) return;
this.submitting.set(true);
this.submitError.set(null);
const payload: GrantUserScopeInput = {
kind: this.newKind(),
...(this.needsValue() ? { value: this.newValue().trim() } : {}),
...(this.newExpiresAt() ? { expiresAt: toIso(this.newExpiresAt()) } : {}),
};
try {
await firstValueFrom(this.service.grant(this.oid(), payload));
// Reset the form (kind reverts to 'self' as the "least
// privileged" default), then refresh the list to show the new
// row.
this.newKind.set('self');
this.newValue.set('');
this.newExpiresAt.set('');
await this.fetch();
} catch (err) {
this.submitError.set(this.translateError(err, 'Could not grant the scope.'));
} finally {
this.submitting.set(false);
}
}
protected async revoke(scopeId: string, label: string): Promise<void> {
const ok = window.confirm(`Revoke scope "${label}"?`);
if (!ok) return;
try {
await firstValueFrom(this.service.revoke(this.oid(), scopeId));
await this.fetch();
} catch (err) {
this.error.set(this.translateError(err, 'Could not revoke the scope.'));
}
}
protected formatScope(kind: string, value: string): string {
return value === '' ? kind : `${kind}:${value}`;
}
protected formatTimestamp(iso: string | null): string {
if (iso === null) return '—';
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
private translateError(err: unknown, fallback: string): string {
if (
typeof err === 'object' &&
err !== null &&
'error' in err &&
typeof (err as { error?: unknown }).error === 'object' &&
(err as { error: { message?: unknown } }).error.message !== undefined
) {
const msg = (err as { error: { message?: unknown } }).error.message;
if (typeof msg === 'string') return msg;
if (Array.isArray(msg) && msg.every((m) => typeof m === 'string')) {
return msg.join(' • ');
}
}
const status = errorStatus(err);
if (status === 403) return 'You do not have admin access on this session.';
if (status === 404) return 'User not found — has this persona ever signed in or been seeded?';
return fallback;
}
}
function toIso(localValue: string): string {
const d = new Date(localValue);
return Number.isNaN(d.getTime()) ? localValue : d.toISOString();
}
function errorStatus(err: unknown): number | null {
if (typeof err === 'object' && err !== null && 'status' in err) {
const s = (err as { status: unknown }).status;
return typeof s === 'number' ? s : null;
}
return null;
}
@@ -1,86 +0,0 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import { AdminUsersService, type AdminUsersPage } from './admin-users.service';
const BFF_BASE = 'http://bff.test/api';
const USERS_URL = `${BFF_BASE}/admin/users`;
const PAGE: AdminUsersPage = {
items: [
{
oid: 'user-oid',
tid: 'tenant-1',
audience: 'workforce',
username: 'jane.doe@apf.example',
displayName: 'Jane Doe',
firstSeenAt: '2026-05-10T10:00:00.000Z',
lastSeenAt: '2026-05-14T08:30:00.000Z',
},
],
total: 1,
limit: 50,
offset: 0,
};
function setup() {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
],
});
return {
service: TestBed.inject(AdminUsersService),
http: TestBed.inject(HttpTestingController),
};
}
describe('AdminUsersService.query', () => {
it('GETs /admin/users with the populated filter fields as URL params', async () => {
const { service, http } = setup();
const promise = firstValueFrom(
service.query({
username: 'jane',
audience: 'workforce',
limit: 25,
offset: 50,
}),
);
const req = http.expectOne((r) => r.url === USERS_URL);
expect(req.request.method).toBe('GET');
expect(req.request.params.get('username')).toBe('jane');
expect(req.request.params.get('audience')).toBe('workforce');
expect(req.request.params.get('limit')).toBe('25');
expect(req.request.params.get('offset')).toBe('50');
req.flush(PAGE);
await expect(promise).resolves.toEqual(PAGE);
});
it('omits filter fields that are empty strings', async () => {
const { service, http } = setup();
void firstValueFrom(
service.query({
username: '',
displayName: '',
limit: 50,
}),
);
const req = http.expectOne((r) => r.url === USERS_URL);
expect(req.request.params.has('username')).toBe(false);
expect(req.request.params.has('displayName')).toBe(false);
expect(req.request.params.get('limit')).toBe('50');
req.flush(PAGE);
});
it('omits undefined filter fields', async () => {
const { service, http } = setup();
void firstValueFrom(service.query({}));
const req = http.expectOne((r) => r.url === USERS_URL);
expect(req.request.params.keys().length).toBe(0);
req.flush(PAGE);
});
});
@@ -1,63 +0,0 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import type { Observable } from 'rxjs';
/**
* One row of the BFF's `GET /api/admin/users` response. Mirrors
* `AdminUserDto` on the BFF side — ISO-string timestamps, no
* `actor_id_hash` (the audit-module invariant per ADR-0013 keeps
* the salted hash inside the audit module; the admin directory
* uses Entra `oid` directly).
*/
export interface AdminUser {
readonly oid: string;
readonly tid: string;
readonly audience: string;
readonly username: string;
readonly displayName: string;
readonly firstSeenAt: string;
readonly lastSeenAt: string;
}
export interface AdminUsersPage {
readonly items: readonly AdminUser[];
readonly total: number;
readonly limit: number;
readonly offset: number;
}
/** Filter+pagination shape sent as query params. Mirrors `AdminUsersQueryDto`. */
export interface AdminUsersQuery {
readonly username?: string | undefined;
readonly displayName?: string | undefined;
readonly audience?: 'workforce' | 'customer' | undefined;
readonly lastSeenAtFrom?: string | undefined;
readonly lastSeenAtTo?: string | undefined;
readonly limit?: number | undefined;
readonly offset?: number | undefined;
}
/**
* Thin HttpClient wrapper around `GET /api/admin/users`. Same shape
* as `AuditEventsService` — provided in `root` because the users
* page is the only v1 consumer, no per-page DI overhead worth it.
*/
@Injectable({ providedIn: 'root' })
export class AdminUsersService {
private readonly http = inject(HttpClient);
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
query(filters: AdminUsersQuery): Observable<AdminUsersPage> {
let params = new HttpParams();
for (const [key, value] of Object.entries(filters)) {
// Drop empty strings — Nest's ValidationPipe rejects `?foo=`
// as `foo === ''` for IsString / IsISO8601 validators.
if (value === undefined || value === null || value === '') {
continue;
}
params = params.set(key, String(value));
}
return this.http.get<AdminUsersPage>(`${this.bffBaseUrl}/admin/users`, { params });
}
}
@@ -1,158 +0,0 @@
<section class="users">
<header class="users-header">
<h1 class="title">Users</h1>
<p class="intro">
Read-only directory of every identity that has signed in to portal-shell or portal-admin per
<a
href="https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0020-portal-admin-app.md"
rel="noopener"
target="_blank"
>ADR-0020</a
>. Every query run on this page emits an <code>admin.users.query</code> audit row — directory
access is itself auditable.
</p>
</header>
<form class="filters" (submit)="$event.preventDefault(); search()" aria-labelledby="filters-h">
<h2 id="filters-h" class="filters-heading">Filters</h2>
<div class="filters-grid">
<label class="filter">
<span class="filter-label">Username starts with</span>
<input
type="text"
name="username"
[ngModel]="username()"
(ngModelChange)="username.set($event)"
placeholder="jane.doe"
/>
</label>
<label class="filter">
<span class="filter-label">Display name contains</span>
<input
type="text"
name="displayName"
[ngModel]="displayName()"
(ngModelChange)="displayName.set($event)"
placeholder="Doe"
/>
</label>
<label class="filter">
<span class="filter-label">Audience</span>
<select name="audience" [ngModel]="audience()" (ngModelChange)="audience.set($event)">
<option value="">Any</option>
<option value="workforce">workforce</option>
<option value="customer">customer</option>
</select>
</label>
<label class="filter">
<span class="filter-label">Last seen from</span>
<input
type="datetime-local"
name="lastSeenAtFrom"
[ngModel]="lastSeenAtFrom()"
(ngModelChange)="lastSeenAtFrom.set($event)"
/>
</label>
<label class="filter">
<span class="filter-label">Last seen to</span>
<input
type="datetime-local"
name="lastSeenAtTo"
[ngModel]="lastSeenAtTo()"
(ngModelChange)="lastSeenAtTo.set($event)"
/>
</label>
<label class="filter">
<span class="filter-label">Page size</span>
<select name="limit" [ngModel]="limit()" (ngModelChange)="limit.set(+$event)">
@for (size of pageSizes; track size) {
<option [value]="size">{{ size }}</option>
}
</select>
</label>
</div>
<div class="filters-actions">
<button type="submit" class="btn btn--primary" [disabled]="loading()">Apply filters</button>
<button
type="button"
class="btn btn--secondary"
(click)="clearFilters()"
[disabled]="loading()"
>
Reset
</button>
</div>
</form>
<div class="status-bar" role="status" aria-live="polite">
@if (loading()) {
<span class="status-line">Loading…</span>
} @else if (error(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
} @else if (page(); as p) {
<span class="status-line">{{ resultRange() }}</span>
}
</div>
@if (page(); as p) { @if (p.items.length === 0) {
<p class="empty">No users match the current filters.</p>
} @else {
<div class="table-wrap">
<table class="users-table">
<thead>
<tr>
<th scope="col">Display name</th>
<th scope="col">Username</th>
<th scope="col">Audience</th>
<th scope="col">First seen</th>
<th scope="col">Last seen</th>
<th scope="col">OID</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (user of p.items; track user.oid) {
<tr>
<td class="cell-name">{{ user.displayName }}</td>
<td class="cell-username">{{ user.username }}</td>
<td class="cell-aud">
<span class="aud-badge">{{ user.audience }}</span>
</td>
<td class="cell-timestamp">{{ formatTimestamp(user.firstSeenAt) }}</td>
<td class="cell-timestamp">{{ formatTimestamp(user.lastSeenAt) }}</td>
<td class="cell-oid">{{ user.oid }}</td>
<td class="cell-actions">
<a
class="btn btn--secondary"
[routerLink]="['/users', user.oid, 'scopes']"
[attr.aria-label]="'Manage scopes for ' + user.displayName"
>
Manage scopes
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
<nav class="pagination" aria-label="Pagination">
<button
type="button"
class="btn btn--secondary"
(click)="previous()"
[disabled]="!hasPreviousPage() || loading()"
>
Previous
</button>
<button
type="button"
class="btn btn--secondary"
(click)="next()"
[disabled]="!hasNextPage() || loading()"
>
Next
</button>
</nav>
} }
</section>
@@ -1,285 +0,0 @@
.users {
max-width: 1200px;
margin: 0 auto;
}
.users-header {
margin-bottom: 1.5rem;
}
.title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.intro {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
code {
padding: 0.0625rem 0.25rem;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.05);
font-family: ui-monospace, monospace;
font-size: 0.875em;
}
a {
color: var(--color-brand-primary-600, #2563eb);
text-decoration: underline;
}
}
:host-context(.dark) .intro {
color: #9ca3af;
code {
background-color: rgba(255, 255, 255, 0.08);
}
a {
color: var(--color-brand-primary-300, #93c5fd);
}
}
.filters {
padding: 1rem;
margin-bottom: 1rem;
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
:host-context(.dark) .filters {
background-color: #111827;
border-color: #1f2937;
}
.filters-heading {
margin: 0 0 0.75rem;
font-size: 1rem;
font-weight: 600;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
.filter {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-label {
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
}
:host-context(.dark) .filter-label {
color: #9ca3af;
}
.filter input,
.filter select {
height: 2rem;
padding: 0 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
background-color: #ffffff;
color: inherit;
font-size: 0.875rem;
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 1px;
border-color: transparent;
}
}
:host-context(.dark) .filter input,
:host-context(.dark) .filter select {
background-color: #0f172a;
border-color: #374151;
color: #e5e7eb;
}
.filters-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.875rem;
}
.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;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&: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:not(:disabled) {
background-color: var(--color-brand-primary-700, #1d4ed8);
}
}
.btn--secondary {
background-color: transparent;
border-color: #d1d5db;
color: inherit;
&:hover:not(:disabled) {
background-color: rgba(0, 0, 0, 0.04);
}
}
:host-context(.dark) .btn--secondary:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.06);
}
.status-bar {
margin-bottom: 0.5rem;
min-height: 1.25rem;
font-size: 0.875rem;
color: #6b7280;
}
.status-line--error {
color: #b91c1c;
font-weight: 500;
}
:host-context(.dark) .status-bar {
color: #9ca3af;
}
:host-context(.dark) .status-line--error {
color: #fca5a5;
}
.empty {
padding: 1.5rem;
text-align: center;
color: #6b7280;
background-color: #ffffff;
border: 1px dashed #d1d5db;
border-radius: 0.5rem;
}
:host-context(.dark) .empty {
background-color: #111827;
border-color: #374151;
color: #9ca3af;
}
.table-wrap {
overflow-x: auto;
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
:host-context(.dark) .table-wrap {
background-color: #111827;
border-color: #1f2937;
}
.users-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
th,
td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
th {
font-weight: 600;
background-color: #f9fafb;
position: sticky;
top: 0;
}
}
:host-context(.dark) .users-table {
th,
td {
border-bottom-color: #1f2937;
}
th {
background-color: #1f2937;
}
}
.cell-name {
font-weight: 500;
}
.cell-username,
.cell-timestamp,
.cell-oid {
font-family: ui-monospace, monospace;
}
.cell-oid {
word-break: break-all;
font-size: 0.75rem;
color: #6b7280;
}
:host-context(.dark) .cell-oid {
color: #9ca3af;
}
.cell-timestamp {
white-space: nowrap;
font-size: 0.8125rem;
}
.aud-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.06);
color: #1f2937;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
:host-context(.dark) .aud-badge {
background-color: rgba(255, 255, 255, 0.08);
color: #e5e7eb;
}
.pagination {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 0.75rem;
}
@@ -1,184 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import {
AdminUsersService,
type AdminUsersPage,
type AdminUsersQuery,
} from './admin-users.service';
import { UsersPage } from './users';
const PAGE: AdminUsersPage = {
items: [
{
oid: 'jane-oid',
tid: 'tenant-1',
audience: 'workforce',
username: 'jane.doe@apf.example',
displayName: 'Jane Doe',
firstSeenAt: '2026-05-10T10:00:00.000Z',
lastSeenAt: '2026-05-14T08:30:00.000Z',
},
{
oid: 'admin-oid',
tid: 'tenant-1',
audience: 'workforce',
username: 'admin@apf.example',
displayName: 'Admin Smith',
firstSeenAt: '2026-05-09T08:00:00.000Z',
lastSeenAt: '2026-05-13T17:45:00.000Z',
},
],
total: 137,
limit: 50,
offset: 0,
};
interface Fixture {
fixture: ReturnType<typeof TestBed.createComponent<UsersPage>>;
query: ReturnType<typeof vi.fn>;
}
function setup(opts?: { initial?: AdminUsersPage | 'error'; status?: number }): Fixture {
const initial = opts?.initial ?? PAGE;
const query = vi.fn().mockImplementation(() => {
if (initial === 'error') {
return throwError(() => ({ status: opts?.status ?? 500 }));
}
return of(initial);
});
TestBed.configureTestingModule({
imports: [UsersPage],
providers: [{ provide: AdminUsersService, useValue: { query } }, provideRouter([])],
});
const fixture = TestBed.createComponent(UsersPage);
return { fixture, query };
}
async function flush(fixture: Fixture['fixture']): Promise<void> {
fixture.detectChanges();
await Promise.resolve();
await Promise.resolve();
fixture.detectChanges();
await fixture.whenStable();
}
describe('UsersPage', () => {
it('runs an initial unfiltered query on init and renders the result table', async () => {
const { fixture, query } = setup();
await flush(fixture);
expect(query).toHaveBeenCalledTimes(1);
expect(query).toHaveBeenCalledWith(expect.objectContaining({ limit: 50, offset: 0 }));
const rows = (fixture.nativeElement as HTMLElement).querySelectorAll<HTMLTableRowElement>(
'.users-table tbody tr',
);
expect(rows.length).toBe(2);
});
it('shows the result range ("12 of 137") in the status bar', async () => {
const { fixture } = setup();
await flush(fixture);
expect(
(fixture.nativeElement as HTMLElement).querySelector('.status-line')?.textContent,
).toContain('12 of 137');
});
it('renders the empty state when the page has no items', async () => {
const { fixture } = setup({
initial: { items: [], total: 0, limit: 50, offset: 0 },
});
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.empty')?.textContent).toContain('No users');
expect(root.querySelector('.users-table')).toBeNull();
});
it('renders a permission-aware error message on 403', async () => {
const { fixture } = setup({ initial: 'error', status: 403 });
await flush(fixture);
const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error');
expect(status?.textContent).toContain('do not have access');
});
it('renders a generic error message on 5xx', async () => {
const { fixture } = setup({ initial: 'error', status: 500 });
await flush(fixture);
const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error');
expect(status?.textContent).toContain('Could not load');
});
it('passes the populated filter fields to the service on Apply', async () => {
const { fixture, query } = setup();
await flush(fixture);
const component = fixture.componentInstance;
(component as unknown as { username: { set: (v: string) => void } }).username.set('jane');
(component as unknown as { audience: { set: (v: string) => void } }).audience.set('workforce');
await component.search();
await flush(fixture);
expect(query).toHaveBeenCalledTimes(2);
const lastCall = query.mock.calls[1]?.[0] as AdminUsersQuery;
expect(lastCall.username).toBe('jane');
expect(lastCall.audience).toBe('workforce');
expect(lastCall.offset).toBe(0);
});
it('resets the offset to 0 on each Apply', async () => {
const { fixture, query } = setup();
await flush(fixture);
const component = fixture.componentInstance;
await component.next();
await flush(fixture);
expect((query.mock.calls[1]?.[0] as AdminUsersQuery).offset).toBe(50);
await component.search();
await flush(fixture);
expect((query.mock.calls[2]?.[0] as AdminUsersQuery).offset).toBe(0);
});
it('clearFilters empties every filter signal and re-queries from offset 0', async () => {
const { fixture, query } = setup();
await flush(fixture);
const component = fixture.componentInstance;
(component as unknown as { username: { set: (v: string) => void } }).username.set('x');
(component as unknown as { displayName: { set: (v: string) => void } }).displayName.set('y');
await component.clearFilters();
await flush(fixture);
const lastCall = query.mock.calls[1]?.[0] as AdminUsersQuery;
expect(lastCall.username).toBeUndefined();
expect(lastCall.displayName).toBeUndefined();
expect(lastCall.offset).toBe(0);
});
it('Next is disabled when the loaded page covers the total', async () => {
const { fixture } = setup({
initial: { items: PAGE.items, total: 2, limit: 50, offset: 0 },
});
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const nextBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('.pagination .btn')).find(
(b) => b.textContent?.trim() === 'Next',
);
expect(nextBtn?.disabled).toBe(true);
});
it('Previous is disabled at offset 0', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const prevBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('.pagination .btn')).find(
(b) => b.textContent?.trim() === 'Previous',
);
expect(prevBtn?.disabled).toBe(true);
});
it('renders the displayName + username + audience badge per row', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.users-table tbody tr');
expect(rows[0]?.querySelector('.cell-name')?.textContent?.trim()).toBe('Jane Doe');
expect(rows[0]?.querySelector('.cell-username')?.textContent?.trim()).toBe(
'jane.doe@apf.example',
);
expect(rows[0]?.querySelector('.aud-badge')?.textContent?.trim()).toBe('workforce');
});
});
@@ -1,159 +0,0 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
AdminUsersService,
type AdminUsersPage,
type AdminUsersQuery,
} from './admin-users.service';
/** Page-size options. Matches the BFF's `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (200). */
const PAGE_SIZES = [25, 50, 100, 200] as const;
/**
* User directory viewer per ADR-0020's v1 admin catalogue. Consumes
* `GET /api/admin/users` from the BFF (PR #141) and renders:
*
* - Filter row: username prefix, displayName contains,
* audience, last-seen-at range, page-size selector.
* - Paginated table with `oid`, `displayName`, `username`,
* `audience`, `firstSeenAt`, `lastSeenAt` columns.
* - Pagination controls + loading / empty / error states.
*
* Mirrors the `/audit` page (PR #136) in shape and signal layout
* so a future contributor lands familiar code regardless of which
* admin module they open first.
*/
@Component({
selector: 'app-users-page',
imports: [FormsModule, RouterLink],
templateUrl: './users.html',
styleUrl: './users.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UsersPage {
private readonly service = inject(AdminUsersService);
protected readonly pageSizes = PAGE_SIZES;
protected readonly username = signal('');
protected readonly displayName = signal('');
protected readonly audience = signal<'' | 'workforce' | 'customer'>('');
protected readonly lastSeenAtFrom = signal('');
protected readonly lastSeenAtTo = signal('');
protected readonly limit = signal<number>(50);
protected readonly offset = signal<number>(0);
protected readonly page = signal<AdminUsersPage | null>(null);
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly hasNextPage = computed(() => {
const p = this.page();
if (p === null) return false;
return p.offset + p.items.length < p.total;
});
protected readonly hasPreviousPage = computed(() => {
const p = this.page();
if (p === null) return false;
return p.offset > 0;
});
protected readonly resultRange = computed(() => {
const p = this.page();
if (p === null) return '';
if (p.items.length === 0) return p.total === 0 ? 'No users' : `0 of ${p.total}`;
const first = p.offset + 1;
const last = p.offset + p.items.length;
return `${first}${last} of ${p.total}`;
});
async search(): Promise<void> {
this.offset.set(0);
await this.fetch();
}
async next(): Promise<void> {
if (!this.hasNextPage()) return;
this.offset.update((o) => o + this.limit());
await this.fetch();
}
async previous(): Promise<void> {
if (!this.hasPreviousPage()) return;
this.offset.update((o) => Math.max(0, o - this.limit()));
await this.fetch();
}
async clearFilters(): Promise<void> {
this.username.set('');
this.displayName.set('');
this.audience.set('');
this.lastSeenAtFrom.set('');
this.lastSeenAtTo.set('');
this.offset.set(0);
await this.fetch();
}
protected formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
private async fetch(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const page = await firstValueFrom(this.service.query(this.buildFilters()));
this.page.set(page);
} catch (err) {
const status = errorStatus(err);
this.error.set(
status === 403
? 'You do not have access to the user directory on this session.'
: 'Could not load the user directory. Try again in a moment.',
);
this.page.set(null);
} finally {
this.loading.set(false);
}
}
private buildFilters(): AdminUsersQuery {
return {
username: this.username().trim() || undefined,
displayName: this.displayName().trim() || undefined,
audience: this.audience() || undefined,
lastSeenAtFrom: this.lastSeenAtFrom() ? toIso(this.lastSeenAtFrom()) : undefined,
lastSeenAtTo: this.lastSeenAtTo() ? toIso(this.lastSeenAtTo()) : undefined,
limit: this.limit(),
offset: this.offset(),
};
}
ngOnInit(): void {
void this.fetch();
}
}
function toIso(localValue: string): string {
// `<input type="datetime-local">` emits `YYYY-MM-DDTHH:mm` (no
// timezone). Treat as the user's local time and convert to ISO
// 8601 so the BFF's IsISO8601 validator accepts it.
const d = new Date(localValue);
return Number.isNaN(d.getTime()) ? localValue : d.toISOString();
}
function errorStatus(err: unknown): number | null {
if (typeof err === 'object' && err !== null && 'status' in err) {
const s = (err as { status: unknown }).status;
return typeof s === 'number' ? s : null;
}
return null;
}
@@ -1,62 +0,0 @@
/**
* 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 = {
/**
* 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.
*
* Relative path: see portal-shell `environment.ts` for the full
* rationale. Both SPAs use `proxy.conf.js` to proxy `/api/*` to
* the BFF, keeping every call same-origin in the browser.
*/
bffApiBaseUrl: '/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',
/**
* Origin of the sibling `portal-shell` SPA — drives the "Open
* Portal Shell" entry in the admin user menu. Symmetric with
* `portal-shell`'s `adminAppUrl`; per ADR-0020 the two SPAs live
* on distinct origins, so this is a raw cross-origin `<a href>`,
* not an Angular route.
*/
shellAppUrl: 'http://localhost:4200',
/**
* Base origin of the Jaeger UI — the trace-explorer the admin
* audit-log viewer links each `trace_id` to. Dev points at the
* compose-managed Jaeger from the `observability` profile (see
* `infra/local/dev.compose.yml`). Prod will switch to whatever
* trace backend the future infrastructure ADR picks (Tempo,
* Grafana Cloud, on-prem Jaeger…) via the per-env file replacement.
*
* Trace URLs are built as `${jaegerBaseUrl}/trace/<id>`.
*/
jaegerBaseUrl: 'http://localhost:16686',
};
-23
View File
@@ -1,23 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>APF Portal Admin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Favicons + PWA manifest. Same image assets as portal-shell —
single visual identity across the apps — with an admin-
specific webmanifest so the PWA install banner reads "APF
Portal Admin" / "Admin" instead of the portal-shell name. -->
<link rel="icon" type="image/svg+xml" href="favicons/favicon.svg" />
<link rel="icon" type="image/png" sizes="96x96" href="favicons/favicon-96x96.png" />
<link rel="shortcut icon" href="favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="favicons/apple-touch-icon.png" />
<link rel="manifest" href="favicons/site.webmanifest" />
<meta name="theme-color" content="#ffffff" />
</head>
<body>
<app-root></app-root>
</body>
</html>
@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<!-- app -->
<trans-unit id="app.skipLink" datatype="html">
<source>Skip to main content</source>
<target>Aller au contenu principal</target>
</trans-unit>
<!-- route -->
<trans-unit id="route.home.title" datatype="html">
<source>APF Portal Admin</source>
<target>Administration APF Portal</target>
</trans-unit>
<trans-unit id="route.audit.title" datatype="html">
<source>Audit log — APF Portal Admin</source>
<target>Journal daudit — Administration APF Portal</target>
</trans-unit>
<trans-unit id="route.users.title" datatype="html">
<source>Users — APF Portal Admin</source>
<target>Utilisateurs — Administration APF Portal</target>
</trans-unit>
<trans-unit id="route.user-scopes.title" datatype="html">
<source>User scopes — APF Portal Admin</source>
<target>Périmètres utilisateur — Administration APF Portal</target>
</trans-unit>
<trans-unit id="route.profile.title" datatype="html">
<source>Profile — APF Portal Admin</source>
<target>Profil — Administration APF Portal</target>
</trans-unit>
<!-- page.home -->
<trans-unit id="page.home.title" datatype="html">
<source> APF Portal Admin </source>
<target> Administration APF Portal </target>
</trans-unit>
<trans-unit id="page.home.intro" datatype="html">
<source> 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. </source>
<target> Back-office dadministration pour le portail APF. Les premiers modules fonctionnels — gestion de contenu, gestion du menu, liste des utilisateurs, visualiseur daudit log — arrivent dans les PRs à venir conformément à lADR-0020. </target>
</trans-unit>
<trans-unit id="page.home.status" datatype="html">
<source> Skeleton — coming soon </source>
<target> Squelette — bientôt disponible </target>
</trans-unit>
</body>
</file>
</xliff>
-5
View File
@@ -1,5 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
-33
View File
@@ -1,33 +0,0 @@
/*
* Global styles for portal-admin.
*
* Same baseline as portal-shell — Tailwind CSS 4 (CSS-first config),
* class-based dark mode, brand palette tokens shared from
* `libs/shared/tokens`. The two apps wear the same visual chrome so
* an APF user moving between portal and admin doesn't feel a context
* switch in the design system itself; what differentiates the admin
* surface is the data density and the "Admin" badge in the shell
* (added once the admin shell PR ships).
*/
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* Brand palette tokens — shared with portal-shell. */
@import '../../../libs/shared/tokens/src/brand-tokens.css';
/*
* The admin shell is a "fills the viewport, never scrolls the body"
* layout: <app-root> is locked at height: 100vh, and <main> owns its
* own overflow-y. If anything inside the shell ever pushes content
* past the viewport (a wide chart, a flex sizing bug, a third-party
* iframe), we'd rather clip than show a phantom body scrollbar plus
* the empty space below the footer that comes with it. The element-
* level overflow on <main> still produces a real, scrollable region
* for content that overflows by design.
*/
html,
body {
overflow-y: hidden;
}
-20
View File
@@ -1,20 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["@angular/localize"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
"references": [
{
"path": "../../libs/shared/charts/tsconfig.lib.json"
},
{
"path": "../../libs/shared/ui/tsconfig.lib.json"
},
{
"path": "../../libs/feature/auth/tsconfig.lib.json"
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals", "@angular/localize"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+30 -239
View File
@@ -4,257 +4,48 @@
# Postgres connection (per ADR-0006) # Postgres connection (per ADR-0006)
# Local dev default: dockerised Postgres on port 5432, schema 'public'. # Local dev default: dockerised Postgres on port 5432, schema 'public'.
# Username / password / db must match infra/local/.env (POSTGRES_USER / DATABASE_URL="postgresql://portal:portal@localhost:5432/portal_dev?schema=public"
# POSTGRES_PASSWORD / POSTGRES_DB) — those are the source of truth,
# this is the BFF view of the same connection.
#
# IMPORTANT — URL encoding. The password is part of the URL userinfo
# segment, so any of these characters must be URL-encoded:
# @ → %40 # → %23 : → %3A / → %2F ? → %3F
# % → %25 & → %26 = → %3D + → %2B ; → %3B
# i.e. if your POSTGRES_PASSWORD is "p@ss#1", DATABASE_URL must read
# "postgresql://portal:p%40ss%231@localhost:5432/portal_dev?schema=public"
# The BFF aborts at boot with a clear error if it detects an unencoded
# special character (see apps/portal-bff/src/config/check-database-url.ts).
DATABASE_URL="postgresql://portal:portal_dev_change_me@localhost:5432/portal_dev?schema=public"
# Observability (per ADR-0012)
# All OTEL_* keys are honoured by the OpenTelemetry SDK directly — see
# apps/portal-bff/src/observability/tracing.ts for the bootstrap.
# Pino log level: 'info' in prod, 'debug' in dev (default if unset).
LOG_LEVEL=debug
OTEL_SERVICE_NAME=portal-bff
OTEL_SERVICE_VERSION=dev
# Default endpoint targets the Collector provisioned in
# infra/local/dev.compose.yml. The /v1/traces suffix is required by
# the HTTP/Protobuf transport.
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# v1 samples 100 % at the app; tail sampling is delegated to the
# Collector (per ADR-0012). Override only for spike investigations.
OTEL_TRACES_SAMPLER=always_on
# Identity / Entra ID app registration (per ADR-0008 / ADR-0009)
# Values come from the project's Entra application registration in the
# Azure Admin Center → App registrations → APF Portal. The four
# *_INSTANCE_URL / *_TENANT_ID / *_CLIENT_ID / *_CLIENT_SECRET keys
# are mandatory; the BFF refuses to boot without them (see
# apps/portal-bff/src/config/check-entra-config.ts).
#
# ENTRA_INSTANCE_URL is the Microsoft login endpoint — usually
# https://login.microsoftonline.com/. The authority used by MSAL is
# `${ENTRA_INSTANCE_URL}${ENTRA_TENANT_ID}` for single-tenant flows,
# or `${ENTRA_INSTANCE_URL}organizations` / `common` for multi-tenant
# (per ADR-0008's dual-audience design). v1 uses the tenant-scoped
# authority; the multi-tenant switch lands when External ID activation
# is needed.
#
# ENTRA_CLIENT_SECRET is the high-value secret of this set. Never
# commit a real value. Production manages it via the deploy platform's
# secret manager (future infrastructure ADR).
ENTRA_INSTANCE_URL=https://login.microsoftonline.com/
ENTRA_TENANT_ID=00000000-0000-0000-0000-000000000000
ENTRA_CLIENT_ID=00000000-0000-0000-0000-000000000000
ENTRA_CLIENT_SECRET=replace_with_real_value
# Redirect URIs registered in Entra alongside the same client id.
# User portal — `/api/auth/callback` is the OIDC return URL; the
# post-logout URL is where Entra sends the browser after RP-initiated
# logout (typically the SPA landing page).
#
# The four `localhost` values below are the **WSL-native default** —
# browser and BFF on the same host, `http://localhost:*` redirect
# URIs (which Entra accepts as the only exception to its HTTPS rule).
# Leave them here in this file regardless of how the docker `apps`
# profile is being run.
#
# For the ADR-0030 dockerised `apps` profile accessed via a
# hostname (e.g. `https://apf-portal.dev-jg.local:4200/`), do NOT
# edit these values — instead override them at the compose level
# from `infra/local/.env`. Compose's `environment:` block on the
# `portal-bff` service interpolates each of these four vars at
# parse time and wins over `env_file:`, so the BFF in the container
# sees the hostname URIs while native `nx serve` keeps reading
# this file's localhost defaults. See
# `infra/README.md` → "Switching between dev modes" for the
# two-mode toggle.
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
# Admin portal — distinct callback per ADR-0020 §"Sessions — distinct
# from `portal-shell`" so Entra routes the response to the matching
# session. Both `ENTRA_REDIRECT_URI` and `ENTRA_ADMIN_REDIRECT_URI`
# must be registered in the same Entra app registration's
# "Redirect URIs" list. Distinct post-logout URL routes admin
# sign-outs to the admin SPA's landing page (port 4300 in dev — see
# apps/portal-admin/project.json `serve.options.port`).
ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/
# Authorization model (per ADR-0025). Points at the JSON file that
# maps tenant-private Entra security-group GUIDs to the closed
# catalogue of `apf-role-*` slugs. The BFF loads it at boot through
# `EntraGroupToRoleResolver` (libs/shared/auth). Unset means the
# resolver runs empty — sign-in still succeeds but every user gets
# zero functional roles (no `apf-role-*` UI). A WARN is logged at
# boot so an operator can spot the missing config. See
# `infra/test-tenant.entra.example.json` for the schema; copy to
# `infra/test-tenant.entra.json` (gitignored) and fill in the real
# GUIDs from the Entra admin centre.
ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json
# Test-tenant per-persona Entra `oid` map (per ADR-0026 PR 2).
# Consumed by `apps/portal-bff/prisma/seed.ts` to upsert Person +
# User + UserScope rows for the 19 test personas. Distinct from
# `ENTRA_GROUP_MAP_PATH` which points at the 24 functional-role
# *group* guids. Unset means `prisma db seed` skips the test-tenant
# section (no Person/User/UserScope rows seeded — lazy creation at
# first sign-in still works). See
# `infra/test-tenant.personas.example.json` for the schema.
TEST_TENANT_PERSONAS_PATH=infra/test-tenant.personas.json
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
# transient pre-auth cookie that carries the OIDC `state` + PKCE
# verifier between the /auth/login redirect and the /auth/callback
# round-trip, and (once ADR-0010 ships) the session cookie's
# integrity layer. Mandatory at boot — the BFF aborts if missing or
# obviously weak (less than 32 base64-decoded bytes ≈ 256 bits of
# entropy). Generate a fresh value per environment:
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
SESSION_SECRET=replace_with_32_random_bytes_base64url
# Redis connection (per ADR-0010). The BFF uses `ioredis` for session
# storage (today: just the connection; the express-session +
# connect-redis middleware lands in the next PR).
#
# REDIS_URL — full URL form including auth. Must match `infra/local/.env`
# (REDIS_PASSWORD + REDIS_PORT) when running against the local Compose
# stack. Production wiring uses Sentinel (REDIS_SENTINEL_HOSTS +
# REDIS_SENTINEL_NAME — future-vars block below) and TLS; the current
# variable supports the dev single-instance shape only.
REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0
# Session payload encryption (per ADR-0010 §"At-rest encryption").
# AES-256-GCM key for encrypting the session JSON that connect-redis
# writes to Redis, so a Redis dump never carries raw user identities
# / future tokens / claims in plaintext. **Distinct** from
# SESSION_SECRET, which only signs the cookie's session-id — never
# reuse one for the other. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# OBO downstream-token cache encryption (per ADR-0014 §"Token cache
# (for OBO)"). AES-256-GCM key for encrypting Entra-issued downstream
# tokens cached in Redis under `obo:{actor_id_hash}:{resource}`.
# **Dedicated key** — must differ from SESSION_ENCRYPTION_KEY so a
# leak of one does not cascade into the other. The boot validator
# refuses an identical value. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# BFF JWKS signing material (per ADR-0014 §"Service strategy"). The
# BFF mints short-lived `X-User-Assertion` JWTs to propagate user
# identity to non-Entra downstreams; downstreams verify the signature
# against `/.well-known/jwks.json`. Both values are mandatory at boot.
#
# Generate an RSA private key:
# mkdir -p apps/portal-bff/.secrets && \
# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
# -out apps/portal-bff/.secrets/jwks.pem
#
# RSA-2048 is the minimum the validator accepts; 3072 is the
# default recommendation. EC P-256 / P-384 also accepted.
BFF_JWKS_PRIVATE_KEY_PATH=apps/portal-bff/.secrets/jwks.pem
# Stable key id published in the JWKS + emitted in the JWT `kid`
# header. URL-safe charset only ([A-Za-z0-9_-], 4128 chars). Bump
# this when rotating to a new key.
BFF_JWKS_KID=bff-2026-05
# Session timeouts (per ADR-0010). Both optional with sensible
# defaults; override only when staging / prod policy diverges.
# SESSION_IDLE_TIMEOUT_SECONDS — sliding window. Each request
# extends the cookie's `expires` by this many seconds.
# Default 1800 (30 min).
# SESSION_ABSOLUTE_TIMEOUT_SECONDS — hard ceiling. Session is
# destroyed regardless of activity at this age.
# Default 43200 (12 h).
# SESSION_IDLE_TIMEOUT_SECONDS=1800
# SESSION_ABSOLUTE_TIMEOUT_SECONDS=43200
# Per-environment salt used to pseudonymise the user id before it
# lands in audit rows (per ADR-0013 §"Schema") and in Pino app log
# lines (per ADR-0012 §"User id hashing"). Same value must be used
# on both sides so audit and app logs join on `actor_id_hash`.
#
# Rotation invalidates the join key — old rows / log lines can no
# longer be correlated with the new hash. Treat as long-lived per
# environment. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
LOG_USER_ID_SALT=replace_with_32_random_bytes_base64url
# CORS allowlist (per ADR-0009 §"CORS"). Comma-separated list of
# origins (scheme://host[:port]) allowed to call the BFF with
# credentials. The BFF refuses to start without this — silently
# defaulting to localhost is the classic "works in dev, breaks in
# prod" trap.
#
# Local dev: portal-shell on :4200 and portal-admin on :4300 — both
# call the BFF with credentials. Source of truth for the ports:
# `apps/<app>/project.json` `serve.options.port`.
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4300
# Rate limiting (per ADR-0015 §"DoS mitigation"). Both optional
# with conservative defaults; override per environment when traffic
# patterns demand it. The BFF keys buckets by session id when the
# request is authenticated, by remote IP otherwise — rotating
# sessions doesn't dodge the limit.
# RATE_LIMIT_PER_MINUTE — general bucket. Default 120 (~ 2 r/s).
# RATE_LIMIT_AUTH_PER_MINUTE — stricter bucket on /auth/login and
# /auth/callback. Default 10/min, to
# slow brute-force / replay loops
# without inconveniencing legit users.
# RATE_LIMIT_PER_MINUTE=120
# RATE_LIMIT_AUTH_PER_MINUTE=10
# Future env vars introduced by upcoming phases / ADRs: # Future env vars introduced by upcoming phases / ADRs:
# #
# Auth flow (ADR-0009) — additional keys wired as the routes land: # Auth flow (ADR-0009):
# ENTRA_CLIENT_CERT_PATH (alternative to ENTRA_CLIENT_SECRET) # ENTRA_TENANT_ID
# ENTRA_ACCEPTED_TENANT_IDS (CSV; restricts which tenants can sign in # ENTRA_CLIENT_ID
# in the multi-tenant phase — empty means # ENTRA_CLIENT_SECRET (or ENTRA_CLIENT_CERT_PATH)
# "only ENTRA_TENANT_ID is accepted") # ENTRA_ACCEPTED_TENANT_IDS
# ENTRA_REDIRECT_URI
# ENTRA_POST_LOGOUT_REDIRECT_URI
# SESSION_SECRET
# #
# Sessions (ADR-0010) — additional keys wired as the layers land: # Sessions (ADR-0010):
# REDIS_SENTINEL_HOSTS (CSV `host:port,host:port,…`; prod HA) # REDIS_URL (or REDIS_SENTINEL_HOSTS + REDIS_SENTINEL_NAME)
# REDIS_SENTINEL_NAME (master name in Sentinel; prod HA) # REDIS_PASSWORD
# REDIS_TLS ('true' in prod) # REDIS_TLS ('true' in prod)
# SESSION_ENCRYPTION_KEY (32-byte base64)
# SESSION_IDLE_TIMEOUT_SECONDS (default 1800)
# SESSION_ABSOLUTE_TIMEOUT_SECONDS (default 43200)
# #
# MFA (ADR-0011): # MFA (ADR-0011):
# MFA_FRESHNESS_SECONDS (default 600) # MFA_FRESHNESS_SECONDS (default 600)
# #
# Observability (ADR-0012):
# LOG_LEVEL ('info' in prod, 'debug' in dev)
# LOG_USER_ID_SALT (per-environment salt)
# OTEL_SERVICE_NAME ('portal-bff')
# OTEL_SERVICE_VERSION
# OTEL_RESOURCE_ATTRIBUTES
# OTEL_EXPORTER_OTLP_ENDPOINT
# OTEL_EXPORTER_OTLP_PROTOCOL ('http/protobuf')
# OTEL_TRACES_SAMPLER ('always_on' in v1)
#
# Audit trail (ADR-0013): # Audit trail (ADR-0013):
# AUDIT_DATABASE_URL (separate creds, role 'audit_writer') # AUDIT_DATABASE_URL (separate creds, role 'audit_writer')
# AUDIT_ARCHIVER_DATABASE_URL (role 'audit_archiver', for the retention purge job) # AUDIT_ARCHIVER_DATABASE_URL (role 'audit_archiver', for the retention purge job)
# AUDIT_RETENTION_DAYS (default 365) # AUDIT_RETENTION_DAYS (default 365)
# #
# Downstream API access (ADR-0014): # Downstream API access (ADR-0014):
# OBO_CACHE_ENCRYPTION_KEY — wired # OBO_CACHE_ENCRYPTION_KEY (32-byte base64, distinct from SESSION_ENCRYPTION_KEY)
# BFF_JWKS_PRIVATE_KEY_PATH — wired # BFF_JWKS_PRIVATE_KEY_PATH
# BFF_JWKS_KID — wired # BFF_JWKS_KID
# <SERVICE>_API_BASE_URL (per integrated downstream — lands with the first integration) # <SERVICE>_API_BASE_URL (per integrated downstream)
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000 — lands with the first integration) # <SERVICE>_TIMEOUT_MS (optional, defaults to 5000)
# AI service relay (ADR-0024) — the BFF dials apf-ai-service over
# native gRPC HTTP/2 and bridges chat streams to SSE for the SPA.
# apf-ai-service runs from its own repo (../apf-ai-service); use
# that repo's docker-compose.yml to bring it up locally, then point
# AI_SERVICE_GRPC_ENDPOINT here at the host-published port. No
# JWT/auth on the wire in v1 — the Principal travels in the proto
# body (per ADR-0024 §"Sub-decision 4 — POC unsigned principal").
AI_SERVICE_GRPC_ENDPOINT=localhost:8080
AI_SERVICE_CLIENT_ID=apf-portal-dev
# Set to 'true' in preprod/prod (h2 + TLS via the edge proxy);
# 'false' in dev for h2c against the local apf-ai-service.
AI_SERVICE_GRPC_TLS=false
-14
View File
@@ -6,19 +6,5 @@ module.exports = {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }], '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
}, },
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
// Several deps ship ESM-only; without an explicit transform
// exception ts-jest skips node_modules and Jest fails parsing the
// import statements. Listed by name rather than a broad pattern
// so each new ESM-only dep is a conscious addition.
//
// The pattern walks pnpm's deep layout: any path under
// `/node_modules/` is ignored UNLESS the path also contains one
// of the listed package fragments — catches both the hoisted
// symlink at `node_modules/<pkg>/...` and the pnpm-internal real
// path at `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>/...`.
//
// jose — JOSE primitives (signed-assertion strategy, PR #138).
// @scalar/ — Scalar API reference UI + transitive ESM bits.
transformIgnorePatterns: ['/node_modules/(?!.*(jose|@scalar/))'],
coverageDirectory: '../../coverage/apps/portal-bff', coverageDirectory: '../../coverage/apps/portal-bff',
}; };
@@ -1,67 +0,0 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "audit";
-- CreateEnum
CREATE TYPE "audit"."AuditAudience" AS ENUM ('workforce', 'customer');
-- CreateEnum
CREATE TYPE "audit"."AuditOutcome" AS ENUM ('success', 'failure', 'denied');
-- CreateTable
CREATE TABLE "audit"."events" (
"id" UUID NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"event_type" TEXT NOT NULL,
"audience" "audit"."AuditAudience" NOT NULL,
"actor_id_hash" TEXT,
"trace_id" TEXT,
"subject" TEXT,
"outcome" "audit"."AuditOutcome" NOT NULL,
"payload" JSONB,
CONSTRAINT "events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "events_created_at_idx" ON "audit"."events"("created_at");
-- CreateIndex
CREATE INDEX "events_event_type_idx" ON "audit"."events"("event_type");
-- CreateIndex
CREATE INDEX "events_trace_id_idx" ON "audit"."events"("trace_id");
-- ============================================================
-- Append-only contract per ADR-0013
-- ============================================================
-- The schema-level DEFAULT PRIVILEGES set in
-- infra/local/init/postgres/01-init.sql only fire when audit_owner
-- creates the object. This migration runs as a privileged migrator
-- (the same login that owns the public schema), so the table is
-- initially owned by that user and audit_writer/reader/archiver
-- have no grants. Re-apply the owner transfer + grants explicitly
-- so the runtime role contract holds.
ALTER TABLE "audit"."events" OWNER TO audit_owner;
ALTER TYPE "audit"."AuditAudience" OWNER TO audit_owner;
ALTER TYPE "audit"."AuditOutcome" OWNER TO audit_owner;
GRANT INSERT ON "audit"."events" TO audit_writer;
GRANT SELECT ON "audit"."events" TO audit_reader;
-- audit_archiver needs both DELETE *and* SELECT: per ADR-0013 it
-- removes rows "older than retention", which means evaluating a
-- WHERE clause on `created_at`. Postgres requires SELECT on every
-- column referenced in DELETE's WHERE, even when the row count is
-- the only thing returned. SELECT here does NOT widen the contract
-- — UPDATE / TRUNCATE remain ungranted to every role.
GRANT SELECT, DELETE ON "audit"."events" TO audit_archiver;
-- USAGE on the enum types is needed by every role that touches the
-- column. Without it, audit_writer's INSERT fails with "permission
-- denied for type audit.AuditAudience".
GRANT USAGE ON TYPE "audit"."AuditAudience" TO audit_writer, audit_reader, audit_archiver;
GRANT USAGE ON TYPE "audit"."AuditOutcome" TO audit_writer, audit_reader, audit_archiver;
-- No GRANT for UPDATE / TRUNCATE to anyone, including audit_owner
-- at runtime — schema migrations alone amend the table going
-- forward.
@@ -1,29 +0,0 @@
-- Users directory (per ADR-0020 §"v1 scope — User list").
--
-- Persistent ledger of identities the BFF has seen sign in to either
-- portal-shell or portal-admin. Upserted by `UserDirectoryService`
-- at every sign-in. Read by the future `GET /api/admin/users` admin
-- endpoint. Entra ID is the source of truth for identity; this
-- table is the BFF's local cache so the admin UI does not have to
-- re-query the IdP at every page render.
-- CreateTable
CREATE TABLE "users" (
"oid" TEXT NOT NULL,
"tid" TEXT NOT NULL,
"audience" TEXT NOT NULL,
"username" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"first_seen_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_seen_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("oid")
);
-- DESC index supports the default admin sort ("most recently active first")
-- without a server-side sort.
CREATE INDEX "users_last_seen_at_idx" ON "users"("last_seen_at" DESC);
-- Plain (default ASC) index supports prefix matching on the
-- admin-side username filter.
CREATE INDEX "users_username_idx" ON "users"("username");
@@ -1,121 +0,0 @@
-- Organisational hierarchy (per ADR-0027).
--
-- Region → Delegation → Structure. The portal's ADR-0025 scope-axis
-- dereferences these three layers — the BFF guard
-- `principalCoversResource` walks etablissement → delegation → region
-- to decide whether a scope covers a resource. The schema lives in
-- the public schema; ADR-0029 will populate the full APF inventory
-- via the cascade sync, additively into the columns defined here.
--
-- Inline seed at the bottom of this file: just the codes the
-- test tenant exercises (Région Nouvelle-Aquitaine, Délégation
-- Gironde, two médico-social structures + the APF national siège).
-- Superseded — not extended — by ADR-0029's cascade sync once it
-- ships.
-- CreateTable
CREATE TABLE "regions" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "regions_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "delegations" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"region_code" TEXT NOT NULL,
CONSTRAINT "delegations_pkey" PRIMARY KEY ("code")
);
-- Closed-set CHECK on Structure.kind. Mirrors STRUCTURE_KINDS in
-- apps/portal-bff/src/structures/structure-kind.ts. The CI drift gate
-- (scripts/check-catalogue-drift.mjs) asserts the TS constant matches
-- the values used in code; this CHECK constraint provides the
-- equivalent guarantee at the DB layer for any code path that
-- bypasses the type system.
CREATE TABLE "structures" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"finess" TEXT,
"siret" TEXT,
"code_paie" TEXT,
"delegation_code" TEXT,
CONSTRAINT "structures_pkey" PRIMARY KEY ("code"),
CONSTRAINT "structures_kind_check" CHECK (
"kind" IN (
'medico_social',
'antenne',
'dispositif',
'entreprise_adaptee',
'mouvement',
'administratif',
'siege'
)
)
);
-- CreateIndex
CREATE INDEX "delegations_region_code_idx" ON "delegations"("region_code");
-- CreateIndex
CREATE UNIQUE INDEX "structures_finess_key" ON "structures"("finess");
-- CreateIndex
CREATE UNIQUE INDEX "structures_siret_key" ON "structures"("siret");
-- CreateIndex
CREATE UNIQUE INDEX "structures_code_paie_key" ON "structures"("code_paie");
-- CreateIndex
CREATE INDEX "structures_kind_idx" ON "structures"("kind");
-- CreateIndex
CREATE INDEX "structures_delegation_code_idx" ON "structures"("delegation_code");
-- AddForeignKey
ALTER TABLE "delegations" ADD CONSTRAINT "delegations_region_code_fkey"
FOREIGN KEY ("region_code") REFERENCES "regions"("code")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
-- ON DELETE SET NULL because delegationCode is optional in the Prisma
-- schema (Structure.delegation Delegation?). Matches Prisma's default
-- for nullable relations; the drift gate would otherwise flag this
-- on every `prisma migrate dev`.
ALTER TABLE "structures" ADD CONSTRAINT "structures_delegation_code_fkey"
FOREIGN KEY ("delegation_code") REFERENCES "delegations"("code")
ON DELETE SET NULL ON UPDATE CASCADE;
-- ------------------------------------------------------------------
-- Test-tenant reference seed (per ADR-0027 §"Seeding posture").
--
-- Region Nouvelle-Aquitaine + Délégation Gironde + the structures
-- referenced by the 19 personas in notes/test-tenant-role-assignments.md:
-- - 0330800013 (APF Bordeaux, FINESS = code)
-- - 0330800021 (Complexe Mérignac, FINESS = code)
-- - 'siege' (APF national headquarters)
--
-- Cascade-sync (ADR-0029) supersedes this seed entirely when it
-- ships — the cleanup migration responsible for that truncates and
-- repopulates from cascade's authoritative inventory.
-- ------------------------------------------------------------------
INSERT INTO "regions" ("code", "name") VALUES
('75', 'Nouvelle-Aquitaine');
INSERT INTO "delegations" ("code", "name", "region_code") VALUES
('33', 'Gironde', '75');
INSERT INTO "structures" ("code", "name", "kind", "finess", "delegation_code") VALUES
('0330800013', 'APF Bordeaux', 'medico_social', '0330800013', '33'),
('0330800021', 'Complexe Mérignac', 'medico_social', '0330800021', '33');
-- Siège has no delegation parent and no FINESS/SIRET/codePaie (those
-- columns stay NULL — the unique indexes tolerate multiple NULLs).
INSERT INTO "structures" ("code", "name", "kind") VALUES
('siege', 'Siège APF France handicap', 'siege');
@@ -1,19 +0,0 @@
-- Rename `users` -> `user_directory_entries`.
--
-- Prepares for ADR-0026 PR 1 which introduces a NEW `User` model
-- (UUID PK + FK to `Person` + `lastSignInAt`) with materially
-- different semantics. The existing model (ADR-0020 sign-in cache,
-- Entra `oid` as PK) keeps its role but gets a more precise name
-- that won't collide.
--
-- Mechanical refactor only — same columns, same indexes, same
-- constraints. ADR-0026 PR 1 lands the new `users` table with
-- the new semantics.
ALTER TABLE "users" RENAME TO "user_directory_entries";
-- Rename the PK constraint + indexes so they track the new table name.
-- Postgres doesn't auto-rename these on ALTER TABLE RENAME.
ALTER INDEX "users_pkey" RENAME TO "user_directory_entries_pkey";
ALTER INDEX "users_last_seen_at_idx" RENAME TO "user_directory_entries_last_seen_at_idx";
ALTER INDEX "users_username_idx" RENAME TO "user_directory_entries_username_idx";
@@ -1,84 +0,0 @@
-- Identity model (per ADR-0026 PR 1).
--
-- Person golden record + User portal-account overlay (1-to-0-or-1)
-- + UserScope (the table behind the ADR-0025 scope axis). Distinct
-- from `user_directory_entries` (ADR-0020 sign-in cache, renamed in
-- the previous migration) — those keep their role; these are new.
--
-- No seed data — the test-tenant scope rows ship in ADR-0026 PR 2
-- via `prisma/seed.ts`, after this schema is in place.
-- CreateTable
CREATE TABLE "persons" (
"id" UUID NOT NULL,
"first_name" TEXT NOT NULL,
"last_name" TEXT NOT NULL,
"email" TEXT,
"source" TEXT NOT NULL,
"external_id" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "persons_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"person_id" UUID NOT NULL,
"entra_oid" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"last_sign_in_at" TIMESTAMPTZ(6),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_scopes" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"kind" TEXT NOT NULL,
"value" TEXT NOT NULL DEFAULT '',
"source" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMPTZ(6),
CONSTRAINT "user_scopes_pkey" PRIMARY KEY ("id")
);
-- Person indexes
CREATE INDEX "persons_source_idx" ON "persons"("source");
CREATE INDEX "persons_external_id_idx" ON "persons"("external_id");
CREATE INDEX "persons_email_idx" ON "persons"("email");
-- User indexes — both unique constraints back btree indexes.
CREATE UNIQUE INDEX "users_person_id_key" ON "users"("person_id");
CREATE UNIQUE INDEX "users_entra_oid_key" ON "users"("entra_oid");
-- UserScope indexes — the unique constraint backs the composite,
-- the plain index supports the read-by-userId hot path on sign-in.
CREATE UNIQUE INDEX "user_scopes_user_id_kind_value_key" ON "user_scopes"("user_id", "kind", "value");
CREATE INDEX "user_scopes_user_id_idx" ON "user_scopes"("user_id");
-- Foreign keys.
--
-- User.person_id is REQUIRED → ON DELETE RESTRICT (Prisma default for
-- required relations). Removing a Person with a portal User must be
-- an explicit two-step in the admin path; never an accidental cascade.
ALTER TABLE "users" ADD CONSTRAINT "users_person_id_fkey"
FOREIGN KEY ("person_id") REFERENCES "persons"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- UserScope.user_id has explicit `onDelete: Cascade` in the Prisma
-- schema — revoking a User wipes their scope rows in one delete,
-- which is the desired admin-UI semantic (deactivating an account
-- should not leave dangling authorisation rows).
ALTER TABLE "user_scopes" ADD CONSTRAINT "user_scopes_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+5 -333
View File
@@ -1,341 +1,13 @@
// Prisma schema for portal-bff. // This is your Prisma schema file,
// // learn more about it in the docs: https://pris.ly/d/prisma-schema
// `multiSchema` preview is enabled because per ADR-0013 the audit log
// lives in its own `audit` schema with role-based append-only access // Get a free hosted Postgres database in seconds: `npx create-db`
// (audit_owner / audit_writer / audit_reader / audit_archiver). The
// public schema holds the regular business data; only audit.events
// lives in audit.
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["multiSchema"]
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
schemas = ["public", "audit"]
}
// ============================================================
// Audit log (per ADR-0013)
// ============================================================
//
// Append-only by Postgres role grants — the schema, roles, and
// default privileges are provisioned by infra/local/init/postgres/
// 01-init.sql in dev and the equivalent production manifest. The
// migration that creates this table re-applies the grants
// explicitly and ALTERs the table owner to `audit_owner` so the
// runtime role contract holds even when the migration runs as a
// privileged migrator account.
//
// At runtime, the BFF wraps every INSERT into this table in a
// transaction that begins with `SET LOCAL ROLE audit_writer`, so
// even a compromised BFF connection cannot UPDATE / TRUNCATE /
// DELETE — those grants are not on `audit_writer`.
enum AuditAudience {
workforce
customer
@@schema("audit")
}
enum AuditOutcome {
success
failure
denied
@@schema("audit")
}
// ============================================================
// User directory (per ADR-0020 §"v1 scope — User list")
// ============================================================
//
// Persistent ledger of every identity that has signed in to either
// portal-shell or portal-admin. Upserted at sign-in by
// `UserDirectoryService.recordSignIn` (called from
// `SessionEstablisher.establish`), read by the future
// `GET /api/admin/users` endpoint per ADR-0020 §"User list
// (read-only)".
//
// **Not the source of truth for identity.** Entra ID is. This table
// is a cache the BFF maintains so the admin UI can list "everyone
// who's ever signed in" without re-querying the directory at
// every render. Per-tenant `oid` is the join key on the audit side
// (combined with the salted hash) — never the BFF's primary actor
// identifier elsewhere.
//
// **Distinct from the upcoming ADR-0026 `User` model.** That one
// (UUID PK + FK to `Person` + lazy-created at OIDC callback) is the
// portal-account overlay on a `Person` golden record. This
// `UserDirectoryEntry` is the ADR-0020 sign-in cache — different
// semantics, kept apart so neither concept overloads the other.
//
// **No PII redaction on read.** Per ADR-0013 the audit module
// hashes the actor id to defend against an audit-log dump leaking
// who-did-what. This table is the *deliberate* PII storage: an
// admin browsing the user list explicitly wants display names and
// usernames. The trust boundary is the admin role gate
// (ADR-0020 §"Auth — `admin` role claim").
model UserDirectoryEntry {
// Entra `oid` — stable per-user identifier inside the tenant.
// Used as the natural primary key. Per-tenant uniqueness is
// sufficient: the dual-audience design (ADR-0008) currently
// assumes single workforce tenant; cross-tenant collisions
// become a separate ADR when we onboard the second one.
oid String @id
tid String
audience String
username String
displayName String @map("display_name")
// `first_seen_at` is set once at first sign-in and never
// updated thereafter. Lets the admin list "users since
// <date>" without joining anything.
firstSeenAt DateTime @default(now()) @map("first_seen_at") @db.Timestamptz(6)
// `last_seen_at` is set every time the upsert fires (one upsert
// per sign-in), so "most recently active" can be computed
// without scanning audit.events.
lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(6)
@@map("user_directory_entries")
@@schema("public")
@@index([lastSeenAt(sort: Desc)])
@@index([username])
}
// ============================================================
// Identity model (per ADR-0026)
// ============================================================
//
// `Person` golden record + `User` portal-account overlay (one-to-zero-
// or-one). Distinct from `UserDirectoryEntry` above — that one is the
// ADR-0020 sign-in cache keyed on Entra `oid`; these are the portal's
// stable identity model keyed on UUIDs and form the basis for the
// `@RequireScope` Prisma resolver landing in ADR-0026 PR 2.
//
// `Person` exists whether or not the human ever signs in (workforce
// pre-provisioning, dossier bénéficiaires, alumni). `User` is the
// portal-access overlay, lazy-created at first OIDC callback by
// `PersonAndUserProvisioner.ensureUser`. `UserScope` backs the ADR-0025
// scope axis with opaque `value` strings referencing ADR-0027's
// `Structure.code` / `Delegation.code` / `Region.code` — no FK at the
// DB level so historical scope rows survive structure decommissioning.
model Person {
id String @id @default(uuid()) @db.Uuid
// PII — firstName + lastName are subject to ADR-0013 redaction rules
// when emitted to logs.
firstName String @map("first_name")
lastName String @map("last_name")
// Primary contact email. Indexed for operator lookup but NOT unique
// — two distinct humans genuinely can share emails (shared family
// alias, generic info@ at a small partner organisation, error in an
// upstream feed). ADR-0029's reconciliation flow surfaces candidate
// duplicates for operator confirmation rather than auto-merging.
email String?
// Closed-set catalogue, drift-gated. See
// `apps/portal-bff/src/users/person-source.ts` for the legal values.
// v1: 'self-signin' / 'admin-ui' / 'seed'. ADR-0029 adds 'pleiades'
// and 'acteurs-plus'.
source String
// Upstream-system identifier (Pléiades matricule, Acteurs+ id).
// Nullable in v1 — populated by ADR-0029's syncs.
externalId String? @map("external_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Back-ref. NULL when the Person has no portal account (workforce
// pre-provisioning, dossier bénéficiaire, alumni).
user User?
@@map("persons")
@@schema("public")
@@index([source])
@@index([externalId])
@@index([email])
}
model User {
id String @id @default(uuid()) @db.Uuid
// 1-to-1 with Person. The unique constraint on personId enforces
// "at most one User per Person"; back-ref `Person.user` is the
// other half.
personId String @unique @map("person_id") @db.Uuid
person Person @relation(fields: [personId], references: [id])
// Entra object id — stable per-user identifier inside the tenant.
// Unique because two separate Person+User pairs cannot share an
// Entra identity.
entraOid String @unique @map("entra_oid")
// Entra tenant id. Stored alongside the oid so a future dual-
// audience activation (ADR-0008) can disambiguate.
tenantId String @map("tenant_id")
// Refreshed by `PersonAndUserProvisioner.ensureUser` on every
// sign-in. Surfaced in the future /admin/users/:id screen.
lastSignInAt DateTime? @map("last_sign_in_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
scopes UserScope[]
@@map("users")
@@schema("public")
}
model UserScope {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
// CASCADE so revoking a User wipes their scope rows in one delete.
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// One of the six ADR-0025 scope kinds: 'self' / 'etablissement' /
// 'delegation' / 'region' / 'siege' / 'unrestricted'. Not pulled
// from a Prisma enum because the value's semantics belong to the
// `shared-auth` catalogue, not the database; the drift gate
// already enforces the closed set at the call sites.
kind String
// Per-kind payload — semantics owned by ADR-0027. For 'etablissement'
// the value is a Structure.code; for 'delegation' it is a
// Delegation.code; for 'region' it is a Region.code. Empty string
// for 'self' / 'siege' / 'unrestricted'. NO FK to ADR-0027 tables —
// historical scope rows survive structure decommissioning; the
// admin-UI write path (ADR-0026 PR 2) validates at insert time.
value String @default("")
// Provenance, same catalogue posture as Person.source. v1:
// 'admin-ui' / 'seed'. ADR-0029 adds upstream-sync values and a
// reconciliation policy.
source String
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// When non-null and past, the row is ignored at sign-in. Supports
// interim-director-for-two-months patterns and admin UI scope
// revocations.
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
@@map("user_scopes")
@@schema("public")
@@unique([userId, kind, value])
@@index([userId])
}
// ============================================================
// Organisational hierarchy (per ADR-0027)
// ============================================================
//
// Region → Delegation → Structure. The portal's scope-axis
// dereferences these three layers — the BFF guard
// `principalCoversResource` walks etablissement → delegation →
// region to decide whether a scope covers a resource (per
// ADR-0025).
//
// Population in v1: a small inline seed in the
// `add_org_hierarchy` migration (the codes the test tenant
// exercises). The full APF inventory ships with ADR-0029's
// cascade sync, which writes additively into these columns —
// no schema churn at sync time.
model Region {
// INSEE region code (2 digits — '75' Nouvelle-Aquitaine,
// '11' Île-de-France). Externally meaningful and stable
// across reorgs; doubles as primary key.
code String @id
name String
delegations Delegation[]
@@map("regions")
@@schema("public")
}
model Delegation {
// French department code (2-3 chars — '33' Gironde, '2A',
// '971'). Same rationale as Region.code.
code String @id
name String
regionCode String @map("region_code")
region Region @relation(fields: [regionCode], references: [code])
structures Structure[]
@@map("delegations")
@@schema("public")
@@index([regionCode])
}
model Structure {
// Portal-internal stable code, externally meaningful. For
// medico-social structures: code = FINESS (9 digits). For
// non-medico-social: APF-internal slug ('siege',
// 'apf-bdx-merignac', 'ea-toulouse', …). Opaque at the type
// level; matching is string equality, not parsing.
code String @id
name String
// Closed set, drift-gated. Legal values are tracked in
// `apps/portal-bff/src/structures/structure-kind.ts` and
// mirrored by a Postgres CHECK constraint on this column
// (see the migration). `scripts/check-catalogue-drift.mjs`
// asserts the TS constant matches the values used in code.
kind String
// FINESS (9 digits). NULL for non-medico-social structures.
// Unique when present. Cascade's StructureSourceFiness is
// the long-term authoritative carrier; the portal denormalises
// it inline here for v1 scope-axis checks. ADR-0029's sync
// owns the write path.
finess String? @unique
// SIRET (14 chars: 9 SIREN + 5 NIC). NULL when the structure
// is not SIRENE-registered (most antennes, dispositifs).
// Unique when present.
siret String? @unique
// Pléiades payroll code (6 chars). NULL in v1 — populated by
// ADR-0029's Pléiades sync once it ships.
codePaie String? @unique @map("code_paie")
// Parent delegation. NULL for structures not attached to one
// (siège, mouvement national, …).
delegationCode String? @map("delegation_code")
delegation Delegation? @relation(fields: [delegationCode], references: [code])
@@map("structures")
@@schema("public")
@@index([kind])
@@index([delegationCode])
}
model AuditEvent {
id String @id @default(uuid()) @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
eventType String @map("event_type")
audience AuditAudience
// Salted hash (LOG_USER_ID_SALT) of the actor's stable id. NULL
// when the actor is unauthenticated (e.g. failed login attempt
// before resolving an identity). The same salt is used by the
// BFF Pino logger so audit and app logs cross-correlate on this
// field.
actorIdHash String? @map("actor_id_hash")
// W3C trace id (32 hex chars) of the request that produced the
// event. Cross-correlates with traces in Jaeger and with Pino
// log lines that carry the same `trace_id` field. NULL only if
// the event was emitted outside any inbound request (e.g. a
// future cron job).
traceId String? @map("trace_id")
// Free-form identifier of what the event is *about* — typically
// a domain entity URI like `user:42` or `dossier:xyz`. NULL when
// the event is system-wide and has no clear subject.
subject String?
outcome AuditOutcome
// Event-specific structured detail. Redaction of PII is the
// caller's responsibility; the BFF Pino redact list is the
// reference allow-/deny-list.
payload Json?
@@map("events")
@@schema("audit")
@@index([createdAt])
@@index([eventType])
@@index([traceId])
} }
-291
View File
@@ -1,291 +0,0 @@
/**
* Test-tenant seed per ADR-0026 PR 2.
*
* Run via:
* pnpm exec prisma db seed
*
* Idempotent: re-running this script preserves existing rows. The
* checks for "row already there" run against the unique constraints:
* - User.entraOid (skipping the cold path of
* PersonAndUserProvisioner if the row exists),
* - UserScope.(userId, kind, value).
*
* The seed creates Person + User + UserScope rows for the 19 personas
* provisioned in the `apfrd.onmicrosoft.com` test tenant. The
* persona matrix below is transcribed from
* `notes/test-tenant-role-assignments.md` (gitignored) — that file
* remains the human-readable reference; this constant is the source
* of truth for the seed.
*
* **Test-tenant Entra `oid`s** are read from a JSON file pointed at
* by `TEST_TENANT_PERSONAS_PATH` (or `infra/test-tenant.personas.json`
* by default). The file is gitignored — copy
* `infra/test-tenant.personas.example.json` and fill in real values
* from the Entra admin centre. Missing file → seed skips the
* test-tenant section cleanly (no error, just a log line).
*
* **`Person.source = 'seed'`** for every row created here, per the
* PERSON_SOURCES catalogue. Differentiates these rows from
* 'self-signin' (lazy-created at first sign-in) and 'admin-ui' (future
* scope-seeding admin screen, ADR-0026 PR 2b).
*
* **Privileges + functional roles are NOT seeded** — those come from
* Entra at sign-in (Entra `roles` claim + `groups` claim resolved by
* `EntraGroupToRoleResolver`). Only scopes are portal-side data.
*/
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { PrismaClient } from '@prisma/client';
interface PersonaSeed {
readonly slug: string;
readonly email: string;
readonly displayName: string;
/**
* Scope tuples per `notes/test-tenant-role-assignments.md`. The
* `value` is omitted for valueless kinds (self / siege /
* unrestricted) — the seed writes an empty string to the DB
* `value` column, matching the column's default.
*/
readonly scopes: ReadonlyArray<{ readonly kind: string; readonly value?: string }>;
}
const TENANT_DOMAIN = 'apfrd.onmicrosoft.com';
const PERSONAS: ReadonlyArray<PersonaSeed> = [
{
slug: 'admin',
email: `admin@${TENANT_DOMAIN}`,
displayName: 'Admin User',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'directeur-bordeaux',
email: `directeur-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Directeur Bordeaux',
scopes: [{ kind: 'etablissement', value: '0330800013' }],
},
{
slug: 'directeur-complexe',
email: `directeur-complexe@${TENANT_DOMAIN}`,
displayName: 'Directeur Complexe',
scopes: [
{ kind: 'etablissement', value: '0330800013' },
{ kind: 'etablissement', value: '0330800021' },
],
},
{
slug: 'rh-aquitaine',
email: `rh-aquitaine@${TENANT_DOMAIN}`,
displayName: 'RH Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'rh-siege',
email: `rh-siege@${TENANT_DOMAIN}`,
displayName: 'RH Siège',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'collaborateur-simple',
email: `collaborateur-simple@${TENANT_DOMAIN}`,
displayName: 'Collaborateur Simple',
scopes: [{ kind: 'self' }],
},
{
slug: 'tresorier-bordeaux',
email: `tresorier-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Trésorier Bordeaux',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'dpo',
email: `dpo@${TENANT_DOMAIN}`,
displayName: 'DPO',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'it',
email: `it@${TENANT_DOMAIN}`,
displayName: 'IT',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'benevole-aquitaine',
email: `benevole-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Bénévole Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'chef-equipe-bordeaux',
email: `chef-equipe-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Chef Equipe Bordeaux',
scopes: [{ kind: 'etablissement', value: '0330800013' }],
},
{
slug: 'chef-service-bordeaux',
email: `chef-service-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Chef Service Bordeaux',
scopes: [{ kind: 'etablissement', value: '0330800013' }],
},
{
slug: 'directeur-territorial-aquitaine',
email: `directeur-territorial-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Directeur Territorial Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'juriste-siege',
email: `juriste-siege@${TENANT_DOMAIN}`,
displayName: 'Juriste Siège',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'rssi',
email: `rssi@${TENANT_DOMAIN}`,
displayName: 'RSSI',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'communication-siege',
email: `communication-siege@${TENANT_DOMAIN}`,
displayName: 'Communication Siège',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'elu-ca-national',
email: `elu-ca-national@${TENANT_DOMAIN}`,
displayName: 'Elu CA National',
scopes: [{ kind: 'siege' }],
},
{
slug: 'president-cd-aquitaine',
email: `president-cd-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Président CD Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'secretaire-cd-aquitaine',
email: `secretaire-cd-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Secrétaire CD Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
];
async function main(): Promise<void> {
const prisma = new PrismaClient();
try {
await seedTestTenant(prisma);
} finally {
await prisma.$disconnect();
}
}
async function seedTestTenant(prisma: PrismaClient): Promise<void> {
const personasPath = resolve(
process.env['TEST_TENANT_PERSONAS_PATH'] ?? 'infra/test-tenant.personas.json',
);
if (!existsSync(personasPath)) {
console.log(
`[seed] TEST_TENANT_PERSONAS_PATH (${personasPath}) not found — skipping test-tenant seed.`,
);
return;
}
let entraOidBySlug: Record<string, string>;
try {
const raw = readFileSync(personasPath, 'utf8');
entraOidBySlug = JSON.parse(raw) as Record<string, string>;
} catch (err) {
console.error(
`[seed] could not parse ${personasPath}: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
const tenantId = process.env['ENTRA_TENANT_ID'] ?? TENANT_DOMAIN;
const counts = { personsCreated: 0, usersCreated: 0, scopesCreated: 0, skipped: 0 };
for (const persona of PERSONAS) {
const entraOid = entraOidBySlug[persona.slug];
if (entraOid === undefined || typeof entraOid !== 'string' || entraOid === '') {
console.warn(`[seed] persona "${persona.slug}" missing oid in ${personasPath} — skipping.`);
counts.skipped += 1;
continue;
}
const { userId, created } = await ensurePersonAndUser(prisma, persona, entraOid, tenantId);
if (created) {
counts.personsCreated += 1;
counts.usersCreated += 1;
}
for (const scope of persona.scopes) {
const seededNew = await ensureUserScope(prisma, userId, scope.kind, scope.value ?? '');
if (seededNew) counts.scopesCreated += 1;
}
}
console.log(
`[seed] test-tenant complete — created ${counts.personsCreated} Person + ${counts.usersCreated} User + ${counts.scopesCreated} UserScope rows; skipped ${counts.skipped} personas (missing oid).`,
);
}
async function ensurePersonAndUser(
prisma: PrismaClient,
persona: PersonaSeed,
entraOid: string,
tenantId: string,
): Promise<{ userId: string; personId: string; created: boolean }> {
const existing = await prisma.user.findUnique({
where: { entraOid },
select: { id: true, personId: true },
});
if (existing) {
return { userId: existing.id, personId: existing.personId, created: false };
}
const [firstName, lastName] = splitDisplayName(persona.displayName);
const created = await prisma.user.create({
data: {
entraOid,
tenantId,
person: {
create: {
firstName,
lastName,
email: persona.email,
source: 'seed',
},
},
},
select: { id: true, personId: true },
});
return { userId: created.id, personId: created.personId, created: true };
}
async function ensureUserScope(
prisma: PrismaClient,
userId: string,
kind: string,
value: string,
): Promise<boolean> {
const existing = await prisma.userScope.findUnique({
where: { userId_kind_value: { userId, kind, value } },
});
if (existing) return false;
await prisma.userScope.create({
data: { userId, kind, value, source: 'seed' },
});
return true;
}
function splitDisplayName(displayName: string): [string, string] {
const trimmed = displayName.trim();
const space = trimmed.indexOf(' ');
if (space < 0) return [trimmed, ''];
return [trimmed.slice(0, space), trimmed.slice(space + 1).trim()];
}
main().catch((err: unknown) => {
console.error('[seed] failed:', err);
process.exit(1);
});
@@ -1,162 +0,0 @@
import type { Request } from 'express';
import { AdminAuditController } from './admin-audit.controller';
import type { AdminAuditQueryDto } from './audit-query.dto';
import type { AuditReader, AdminAuditPage } from './audit-reader.service';
import type { AdminAuditStatsQueryDto } from './audit-stats.dto';
import type { AuditStatsReader, AdminAuditStats } from './audit-stats.service';
import type { AuditWriter } from '../audit/audit.service';
const PAGE: AdminAuditPage = {
items: [
{
id: '11111111-1111-1111-1111-111111111111',
createdAt: '2026-05-13T10:30:00.000Z',
eventType: 'auth.sign_in',
audience: 'workforce',
actorIdHash: 'hash(jane)',
traceId: 'trace-abc',
subject: 'session:sid-1',
outcome: 'success',
payload: { amr: ['pwd', 'mfa'] },
},
],
total: 1,
limit: 50,
offset: 0,
};
function makeReq(user?: { oid: string }): Request {
return { session: user !== undefined ? { user } : {} } as unknown as Request;
}
const STATS: AdminAuditStats = {
dailyVolume: [{ day: '2026-05-13', count: 1 }],
outcomeBreakdown: [{ outcome: 'success', count: 1 }],
eventTypeByDay: [{ day: '2026-05-13', eventType: 'auth.sign_in', count: 1 }],
total: 1,
};
function makeFixture() {
const auditReader = {
findEvents: jest.fn().mockResolvedValue(PAGE),
};
const auditStatsReader = {
getStats: jest.fn().mockResolvedValue(STATS),
};
const auditWriter = {
adminAuditQuery: jest.fn().mockResolvedValue(undefined),
adminAuditStatsQuery: jest.fn().mockResolvedValue(undefined),
};
const controller = new AdminAuditController(
auditReader as unknown as AuditReader,
auditStatsReader as unknown as AuditStatsReader,
auditWriter as unknown as AuditWriter,
);
return { controller, auditReader, auditStatsReader, auditWriter };
}
describe('AdminAuditController.list', () => {
it('returns the page from AuditReader', async () => {
const { controller } = makeFixture();
const page = await controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminAuditQueryDto);
expect(page).toBe(PAGE);
});
it('forwards the validated DTO to AuditReader as-is', async () => {
const { controller, auditReader } = makeFixture();
const filters: AdminAuditQueryDto = {
eventType: 'auth.sign_in',
audience: 'workforce',
createdAtFrom: '2026-05-01T00:00:00.000Z',
limit: 100,
offset: 0,
};
await controller.list(makeReq({ oid: 'admin-oid' }), filters);
expect(auditReader.findEvents).toHaveBeenCalledWith(filters);
});
it('emits admin.audit.query with the filters + result count as deterrent', async () => {
const { controller, auditWriter } = makeFixture();
const filters: AdminAuditQueryDto = { eventType: 'auth.sign_in', limit: 50 };
await controller.list(makeReq({ oid: 'admin-oid' }), filters);
expect(auditWriter.adminAuditQuery).toHaveBeenCalledWith({
actor: { oid: 'admin-oid' },
filters: { eventType: 'auth.sign_in', limit: 50 },
resultCount: PAGE.items.length,
});
});
it('captures an empty filter object verbatim (the admin asked for everything)', async () => {
const { controller, auditWriter } = makeFixture();
await controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminAuditQueryDto);
expect(auditWriter.adminAuditQuery).toHaveBeenCalledWith(
expect.objectContaining({ filters: {} }),
);
});
it('still returns the page when there is no session.user (defensive — guard should have caught it)', async () => {
const { controller, auditWriter } = makeFixture();
const page = await controller.list(makeReq(), {} as AdminAuditQueryDto);
expect(page).toBe(PAGE);
// No actor → no audit row. The guard is the primary gate; this
// branch exists so a misconfiguration doesn't crash with a
// misleading error.
expect(auditWriter.adminAuditQuery).not.toHaveBeenCalled();
});
it('propagates audit write failures (blocking per ADR-0013)', async () => {
const { controller, auditWriter } = makeFixture();
auditWriter.adminAuditQuery.mockRejectedValueOnce(new Error('audit_writer denied'));
await expect(
controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminAuditQueryDto),
).rejects.toThrow('audit_writer denied');
});
});
describe('AdminAuditController.stats', () => {
it('returns the aggregated stats from AuditStatsReader', async () => {
const { controller } = makeFixture();
const result = await controller.stats(
makeReq({ oid: 'admin-oid' }),
{} as AdminAuditStatsQueryDto,
);
expect(result).toBe(STATS);
});
it('forwards the validated DTO to AuditStatsReader as-is', async () => {
const { controller, auditStatsReader } = makeFixture();
const filters: AdminAuditStatsQueryDto = {
eventType: 'auth.sign_in',
audience: 'workforce',
createdAtFrom: '2026-05-01T00:00:00Z',
};
await controller.stats(makeReq({ oid: 'admin-oid' }), filters);
expect(auditStatsReader.getStats).toHaveBeenCalledWith(filters);
});
it('emits admin.audit.stats.query with the filters + total as the deterrent signal', async () => {
const { controller, auditWriter } = makeFixture();
const filters: AdminAuditStatsQueryDto = { outcome: 'denied' };
await controller.stats(makeReq({ oid: 'admin-oid' }), filters);
expect(auditWriter.adminAuditStatsQuery).toHaveBeenCalledWith({
actor: { oid: 'admin-oid' },
filters: { outcome: 'denied' },
total: STATS.total,
});
});
it('still returns stats when there is no session.user (defensive)', async () => {
const { controller, auditWriter } = makeFixture();
const result = await controller.stats(makeReq(), {} as AdminAuditStatsQueryDto);
expect(result).toBe(STATS);
expect(auditWriter.adminAuditStatsQuery).not.toHaveBeenCalled();
});
it('propagates audit write failures (same blocking posture as list)', async () => {
const { controller, auditWriter } = makeFixture();
auditWriter.adminAuditStatsQuery.mockRejectedValueOnce(new Error('audit_writer denied'));
await expect(
controller.stats(makeReq({ oid: 'admin-oid' }), {} as AdminAuditStatsQueryDto),
).rejects.toThrow('audit_writer denied');
});
});
@@ -1,101 +0,0 @@
import { Controller, Get, Query, Req } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { AdminAuditQueryDto } from './audit-query.dto';
import { AuditReader, type AdminAuditPage } from './audit-reader.service';
import { AdminAuditStatsQueryDto } from './audit-stats.dto';
import { AuditStatsReader, type AdminAuditStats } from './audit-stats.service';
import { RequireAdmin } from './require-admin.decorator';
/**
* `GET /api/admin/audit` — paginated audit-log viewer per
* ADR-0020's v1 admin module catalogue. Reads `audit.events` via
* the `audit_reader` Postgres role (locked inside the `AuditReader`
* service), shapes the result for SPA consumption, and emits an
* `admin.audit.query` row so the read itself is auditable per
* ADR-0020 §"Read actions are also captured … to deter fishing
* expeditions".
*
* The query DTO (`AdminAuditQueryDto`) carries every supported
* filter + pagination knob; Nest's global ValidationPipe rejects
* unknown keys before they reach this handler.
*
* `@RequireAdmin()` at the class level gates the route on the
* `admin` Entra app role per ADR-0020 §"Auth — same Entra ID".
* `@RequireMfa()` is not applied here in v1: the entire admin
* surface already sits behind a freshly-MFA'd session at the
* sign-in entry, and the audit row itself is the per-query
* deterrent. A future security review can layer `@RequireMfa`
* with a tighter freshness here without touching this code's
* shape — that's exactly why the decorator was designed-in.
*/
@ApiTags('admin (audit log)')
@ApiCookieAuth('portal_admin_session')
@Controller('admin/audit')
@RequireAdmin()
export class AdminAuditController {
constructor(
private readonly auditReader: AuditReader,
private readonly auditStats: AuditStatsReader,
private readonly audit: AuditWriter,
) {}
@ApiOperation({
summary: 'Paginated audit-log query — emits `admin.audit.query` on every call',
})
@Get()
async list(@Req() req: Request, @Query() filters: AdminAuditQueryDto): Promise<AdminAuditPage> {
const page = await this.auditReader.findEvents(filters);
// Guard guarantees `req.session.user`; we still defensively
// narrow rather than assert — the audit emission is what
// makes this read auditable, so if we somehow get here without
// an actor we should still surface the read (with no actor
// hash) rather than silently swallow it.
const actorOid = req.session.user?.oid;
if (actorOid !== undefined) {
await this.audit.adminAuditQuery({
actor: { oid: actorOid },
filters: { ...filters },
resultCount: page.items.length,
});
}
return page;
}
/**
* `GET /api/admin/audit/stats` — server-side aggregations over
* the full filtered set (no pagination). Powers the "Charts" tab
* of the audit viewer per the chantier brief.
*
* Emits `admin.audit.stats.query` per call — same fishing-
* expedition deterrent posture as `list` above. Results are
* Redis-cached for 5 minutes inside `AuditStatsReader` to absorb
* the cost of repeated identical queries (the SPA hits this on
* each tab switch + filter apply).
*/
@ApiOperation({
summary:
'Aggregated audit stats (dailyVolume, outcomeBreakdown, eventTypeByDay) for the filtered set',
})
@Get('stats')
async stats(
@Req() req: Request,
@Query() filters: AdminAuditStatsQueryDto,
): Promise<AdminAuditStats> {
const result = await this.auditStats.getStats(filters);
const actorOid = req.session.user?.oid;
if (actorOid !== undefined) {
await this.audit.adminAuditStatsQuery({
actor: { oid: actorOid },
filters: { ...filters },
total: result.total,
});
}
return result;
}
}
@@ -1,282 +0,0 @@
import type { Request, Response } from 'express';
import type { Logger } from 'nestjs-pino';
import type { AuditWriter } from '../audit/audit.service';
import { PRE_AUTH_COOKIE_NAME } from '../auth/auth.cookie';
import { AuthCodeFlowException } from '../auth/auth.errors';
import type { AuthService, PreAuthPayload } from '../auth/auth.service';
import type { EntraConfig } from '../auth/entra-config.token';
import type { SessionEstablisher } from '../auth/session-establisher.service';
import { AdminAuthController } from './admin-auth.controller';
const ENTRA: EntraConfig = {
instanceUrl: 'https://login.microsoftonline.com/',
tenantId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
clientId: '11111111-2222-3333-4444-555555555555',
clientSecret: 's3cret',
redirectUri: 'http://localhost:3000/api/auth/callback',
postLogoutRedirectUri: 'http://localhost:4200/',
adminRedirectUri: 'http://localhost:3000/api/admin/auth/callback',
adminPostLogoutRedirectUri: 'http://localhost:4300/',
authority: 'https://login.microsoftonline.com/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
};
const USER = {
oid: 'admin-oid',
tid: ENTRA.tenantId,
username: 'admin@apf.example',
displayName: 'Admin Smith',
amr: ['pwd', 'mfa'],
roles: ['admin'],
};
const PRE_AUTH: PreAuthPayload = {
state: 'state-nonce',
codeVerifier: 'verifier-secret',
createdAt: 1_000,
};
const ADMIN_LOGOUT_URL = `${ENTRA.authority}/oauth2/v2.0/logout?post_logout_redirect_uri=${encodeURIComponent(ENTRA.adminPostLogoutRedirectUri)}`;
function makeResStub() {
const res = {
cookie: jest.fn(),
clearCookie: jest.fn(),
redirect: jest.fn(),
status: jest.fn(),
json: jest.fn(),
};
res.cookie.mockReturnValue(res);
res.clearCookie.mockReturnValue(res);
res.redirect.mockReturnValue(res);
res.status.mockReturnValue(res);
res.json.mockReturnValue(res);
return res as unknown as Response & typeof res;
}
function makeReqStub(opts?: {
signedCookies?: Record<string, unknown>;
sessionUser?: typeof USER;
}): Request {
return {
signedCookies: opts?.signedCookies ?? {},
session: opts?.sessionUser !== undefined ? { user: opts.sessionUser } : {},
sessionID: 'sid-admin',
} as unknown as Request;
}
function makeFixture(opts?: { completeAuthCodeFlow?: jest.Mock }) {
const beginAuthCodeFlow = jest.fn().mockResolvedValue({
authUrl: 'https://entra.example/authorize?state=state-nonce',
preAuthPayload: PRE_AUTH,
});
const completeAuthCodeFlow = opts?.completeAuthCodeFlow ?? jest.fn().mockResolvedValue(USER);
const buildLogoutUrl = jest.fn().mockReturnValue(ADMIN_LOGOUT_URL);
const authService = {
beginAuthCodeFlow,
completeAuthCodeFlow,
buildLogoutUrl,
} as unknown as AuthService;
const audit = {
signIn: jest.fn().mockResolvedValue(undefined),
signInFailed: jest.fn().mockResolvedValue(undefined),
signOut: jest.fn().mockResolvedValue(undefined),
};
const establisher = {
establish: jest.fn().mockResolvedValue(undefined),
destroy: jest.fn().mockResolvedValue(undefined),
};
const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
return {
controller: new AdminAuthController(
authService,
logger as unknown as Logger,
ENTRA,
audit as unknown as AuditWriter,
establisher as unknown as SessionEstablisher,
),
beginAuthCodeFlow,
completeAuthCodeFlow,
buildLogoutUrl,
audit,
establisher,
logger,
};
}
describe('AdminAuthController.login', () => {
it('passes the admin redirect URI to beginAuthCodeFlow', async () => {
const { controller, beginAuthCodeFlow } = makeFixture();
await controller.login(makeResStub());
expect(beginAuthCodeFlow).toHaveBeenCalledWith(ENTRA.adminRedirectUri);
});
it('writes the pre-auth cookie and 302s to the Entra auth URL', async () => {
const { controller } = makeFixture();
const res = makeResStub();
await controller.login(res);
expect(res.cookie).toHaveBeenCalledWith(
PRE_AUTH_COOKIE_NAME,
expect.any(String),
expect.objectContaining({ signed: true, httpOnly: true }),
);
expect(res.redirect).toHaveBeenCalledWith(302, expect.stringContaining('entra.example'));
});
});
describe('AdminAuthController.callback', () => {
it('passes the admin redirect URI to completeAuthCodeFlow and establishes a surface=admin session', async () => {
const { controller, completeAuthCodeFlow, establisher } = makeFixture();
const res = makeResStub();
const req = makeReqStub({
signedCookies: { [PRE_AUTH_COOKIE_NAME]: JSON.stringify(PRE_AUTH) },
});
await controller.callback(req, res, 'auth-code', PRE_AUTH.state);
expect(completeAuthCodeFlow).toHaveBeenCalledWith(
'auth-code',
PRE_AUTH.state,
PRE_AUTH,
ENTRA.adminRedirectUri,
);
expect(establisher.establish).toHaveBeenCalledWith({
user: USER,
req,
res,
surface: 'admin',
});
expect(res.redirect).toHaveBeenCalledWith(302, ENTRA.adminPostLogoutRedirectUri);
});
it('redirects with ?auth_error=token-exchange-failed when Entra returns error', async () => {
const { controller } = makeFixture();
const res = makeResStub();
const req = makeReqStub({
signedCookies: { [PRE_AUTH_COOKIE_NAME]: JSON.stringify(PRE_AUTH) },
});
await controller.callback(req, res, undefined, undefined, 'access_denied', 'consent declined');
expect(res.redirect).toHaveBeenCalledWith(
302,
expect.stringContaining('auth_error=token-exchange-failed'),
);
// Error redirect lands on the admin post-logout target, not the user-portal one.
expect(res.redirect).toHaveBeenCalledWith(
302,
expect.stringContaining(ENTRA.adminPostLogoutRedirectUri),
);
});
it('redirects with ?auth_error=flow-expired when the pre-auth cookie is missing', async () => {
const { controller, audit } = makeFixture();
const res = makeResStub();
const req = makeReqStub({ signedCookies: {} });
await controller.callback(req, res, 'auth-code', PRE_AUTH.state);
expect(res.redirect).toHaveBeenCalledWith(
302,
expect.stringContaining('auth_error=flow-expired'),
);
expect(audit.signInFailed).toHaveBeenCalledWith(
expect.objectContaining({
failureKind: 'no-pre-auth-cookie',
payload: expect.objectContaining({ surface: 'admin' }),
}),
);
});
it('redirects with the typed error from AuthCodeFlowException', async () => {
const completeAuthCodeFlow = jest
.fn()
.mockRejectedValue(new AuthCodeFlowException({ kind: 'state-mismatch' }));
const { controller, audit } = makeFixture({ completeAuthCodeFlow });
const res = makeResStub();
const req = makeReqStub({
signedCookies: { [PRE_AUTH_COOKIE_NAME]: JSON.stringify(PRE_AUTH) },
});
await controller.callback(req, res, 'auth-code', PRE_AUTH.state);
expect(res.redirect).toHaveBeenCalledWith(
302,
expect.stringContaining('auth_error=state-mismatch'),
);
expect(audit.signInFailed).toHaveBeenCalledWith(
expect.objectContaining({
failureKind: 'state-mismatch',
payload: expect.objectContaining({ surface: 'admin' }),
}),
);
});
});
describe('AdminAuthController.me', () => {
it('returns the public payload with roles when the admin session is populated', () => {
const { controller } = makeFixture();
const res = makeResStub();
const req = makeReqStub({ sessionUser: USER });
controller.me(req, res);
expect(res.json).toHaveBeenCalledWith({
oid: USER.oid,
tid: USER.tid,
username: USER.username,
displayName: USER.displayName,
roles: USER.roles,
});
});
it('throws UnauthorizedException when no user is on the admin session', () => {
const { controller } = makeFixture();
const res = makeResStub();
const req = makeReqStub();
expect(() => controller.me(req, res)).toThrow(/Unauthenticated/);
});
});
describe('AdminAuthController.logout', () => {
it('destroys the admin session, clears the admin cookie + CSRF cookie, redirects to Entra logout', async () => {
const { controller, establisher, buildLogoutUrl } = makeFixture();
const res = makeResStub();
const req = makeReqStub({ sessionUser: USER });
await controller.logout(req, res);
expect(establisher.destroy).toHaveBeenCalledWith({ actor: USER, req });
expect(buildLogoutUrl).toHaveBeenCalledWith(ENTRA.adminPostLogoutRedirectUri);
expect(res.clearCookie).toHaveBeenCalledWith('portal_admin_session', { path: '/' });
expect(res.clearCookie).toHaveBeenCalledWith('portal_csrf', { path: '/' });
expect(res.redirect).toHaveBeenCalledWith(302, ADMIN_LOGOUT_URL);
});
it('uses the __Host- prefixed admin cookie name in production', async () => {
const originalNodeEnv = process.env['NODE_ENV'];
try {
process.env['NODE_ENV'] = 'production';
const { controller } = makeFixture();
const res = makeResStub();
const req = makeReqStub({ sessionUser: USER });
await controller.logout(req, res);
expect(res.clearCookie).toHaveBeenCalledWith('__Host-portal_admin_session', {
path: '/',
});
} finally {
if (originalNodeEnv === undefined) {
delete process.env['NODE_ENV'];
} else {
process.env['NODE_ENV'] = originalNodeEnv;
}
}
});
it('still clears cookies and redirects for an anonymous admin sign-out', async () => {
const { controller, establisher } = makeFixture();
const res = makeResStub();
const req = makeReqStub();
await controller.logout(req, res);
expect(establisher.destroy).toHaveBeenCalledWith({ actor: undefined, req });
expect(res.clearCookie).toHaveBeenCalledWith('portal_admin_session', { path: '/' });
expect(res.redirect).toHaveBeenCalled();
});
});
@@ -1,195 +0,0 @@
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { AuditWriter } from '../audit/audit.service';
import {
PRE_AUTH_COOKIE_NAME,
clearPreAuthCookieOptions,
preAuthCookieOptions,
} from '../auth/auth.cookie';
import { AuthCodeFlowException, type AuthCodeFlowError, authErrorCode } from '../auth/auth.errors';
import { AuthService, type PreAuthPayload } from '../auth/auth.service';
import { ENTRA_CONFIG, type EntraConfig } from '../auth/entra-config.token';
import { SessionEstablisher } from '../auth/session-establisher.service';
import { csrfCookieName } from '../security/csrf-cookie';
import { adminSessionCookieName } from '../session/admin-session-cookie';
/**
* `/api/admin/auth/*` — admin-portal OIDC routes per ADR-0020.
*
* Structurally identical to the user-portal `AuthController`:
* `GET /login` 302s to Entra with PKCE + state in a signed cookie,
* `GET /callback` exchanges the code for tokens, establishes the
* admin session, and redirects to the admin SPA. `GET /me` reads
* the admin session back. `GET /logout` destroys the admin session
* and redirects to Entra's RP-initiated logout.
*
* The key difference is *which* `req.session` we're looking at: the
* session middleware mounted on `/api/admin/*` in `main.ts` resolves
* to the admin-session Redis namespace (`session:admin:*`) and uses
* the `__Host-portal_admin_session` cookie, distinct from
* `__Host-portal_session` used by the user portal. Per ADR-0020
* §"Sessions — distinct from `portal-shell`": signing in to one
* surface does NOT sign in to the other.
*
* Routes here are **deliberately not** guarded with `@RequireAdmin`
* or `@RequireMfa`. The whole point of the login flow is to *create*
* the session that those guards check against. Sub-controllers
* (admin business routes) layer the guards on top.
*/
@ApiTags('auth (admin portal)')
@Controller('admin/auth')
export class AdminAuthController {
constructor(
private readonly authService: AuthService,
private readonly logger: Logger,
@Inject(ENTRA_CONFIG) private readonly entra: EntraConfig,
private readonly audit: AuditWriter,
private readonly sessionEstablisher: SessionEstablisher,
) {}
@ApiOperation({ summary: 'Start the admin OIDC Auth Code + PKCE flow (302 to Entra)' })
@Get('login')
async login(@Res() res: Response): Promise<void> {
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
this.entra.adminRedirectUri,
);
res.cookie(PRE_AUTH_COOKIE_NAME, JSON.stringify(preAuthPayload), preAuthCookieOptions());
res.redirect(302, authUrl);
}
@ApiOperation({
summary: 'Entra callback for the admin surface — establishes the admin session',
})
@Get('callback')
async callback(
@Req() req: Request,
@Res() res: Response,
@Query('code') code?: string,
@Query('state') state?: string,
@Query('error') entraError?: string,
@Query('error_description') entraErrorDescription?: string,
): Promise<void> {
res.clearCookie(PRE_AUTH_COOKIE_NAME, clearPreAuthCookieOptions());
if (entraError) {
this.logger.warn(
{
event: 'auth.entra_error',
surface: 'admin',
entraError,
entraErrorDescription,
},
'AdminAuthCallback',
);
await this.audit.signInFailed({
failureKind: 'entra-error',
payload: { surface: 'admin', entraError, entraErrorDescription },
});
return this.redirectWithError(res, 'token-exchange-failed');
}
if (typeof code !== 'string' || typeof state !== 'string') {
await this.audit.signInFailed({
failureKind: 'missing-code-or-state',
payload: { surface: 'admin' },
});
return this.redirectWithError(res, 'token-exchange-failed');
}
const preAuth = readPreAuthCookie(req);
if (!preAuth) {
this.logger.warn({ event: 'auth.no_pre_auth_cookie', surface: 'admin' }, 'AdminAuthCallback');
await this.audit.signInFailed({
failureKind: 'no-pre-auth-cookie',
payload: { surface: 'admin' },
});
return this.redirectWithError(res, 'flow-expired');
}
try {
const user = await this.authService.completeAuthCodeFlow(
code,
state,
preAuth,
this.entra.adminRedirectUri,
);
await this.sessionEstablisher.establish({ user, req, res, surface: 'admin' });
res.redirect(302, this.entra.adminPostLogoutRedirectUri);
} catch (err) {
if (err instanceof AuthCodeFlowException) {
this.logger.warn(
{ event: 'auth.flow_error', surface: 'admin', failure: err.failure },
'AdminAuthCallback',
);
await this.audit.signInFailed({
failureKind: err.failure.kind,
payload: { surface: 'admin' },
});
return this.redirectWithError(res, err.failure.kind);
}
throw err;
}
}
@ApiOperation({ summary: 'Current admin session payload (includes `roles`)' })
@ApiCookieAuth('portal_admin_session')
@Get('me')
me(@Req() req: Request, @Res() res: Response): void {
const user = req.session.user;
if (!user) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'Unauthenticated',
});
}
res.json({
oid: user.oid,
tid: user.tid,
username: user.username,
displayName: user.displayName,
// Admins benefit from seeing their role assignment in the
// SPA — drives conditional UI for super-admin features later.
// Other surfaces (`/api/auth/me`) still omit it.
roles: user.roles,
});
}
@ApiOperation({ summary: 'RP-initiated admin logout — destroys admin session' })
@ApiCookieAuth('portal_admin_session')
@Get('logout')
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
const user = req.session.user;
const wasAuthenticated = Boolean(user);
const logoutUrl = this.authService.buildLogoutUrl(this.entra.adminPostLogoutRedirectUri);
await this.sessionEstablisher.destroy({ actor: user, req });
res.clearCookie(adminSessionCookieName(), { path: '/' });
res.clearCookie(csrfCookieName(), { path: '/' });
this.logger.log(
{ event: 'auth.signed_out', surface: 'admin', wasAuthenticated },
'AdminAuthLogout',
);
res.redirect(302, logoutUrl);
}
private redirectWithError(res: Response, kind: AuthCodeFlowError['kind']): void {
const url = new URL(this.entra.adminPostLogoutRedirectUri);
url.searchParams.set('auth_error', authErrorCode({ kind } as AuthCodeFlowError));
res.redirect(302, url.toString());
}
}
function readPreAuthCookie(req: Request): PreAuthPayload | null {
const raw = (req.signedCookies as Record<string, unknown>)[PRE_AUTH_COOKIE_NAME];
if (typeof raw !== 'string') {
return null;
}
try {
return JSON.parse(raw) as PreAuthPayload;
} catch {
return null;
}
}
@@ -1,199 +0,0 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import type { Principal } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
interface SessionStub {
user?: {
oid: string;
tid: string;
username: string;
displayName: string;
amr: readonly string[];
roles: readonly string[];
};
principal?: Principal;
}
function makeRequest(opts: {
session?: SessionStub;
method?: string;
originalUrl?: string;
}): Request {
return {
session: opts.session,
method: opts.method ?? 'GET',
originalUrl: opts.originalUrl ?? '/api/admin/me',
} as unknown as Request;
}
function makeContext(req: Request): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
} as unknown as ExecutionContext;
}
function makeAuditStub(): jest.Mocked<Pick<AuditWriter, 'adminAccessDenied'>> {
return {
adminAccessDenied: jest.fn().mockResolvedValue(undefined),
};
}
function principalWithPrivileges(privileges: readonly string[]): Principal {
return {
user: {
id: 'user-oid',
personId: 'user-oid',
entraOid: 'user-oid',
tenantId: 't',
displayName: 'Jane',
},
privileges: privileges as Principal['privileges'],
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
describe('AdminRoleGuard', () => {
it('throws 401 when the request has no session at all', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(makeRequest({ session: undefined }));
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('throws 401 when the session exists but no principal nor legacy user is bound', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(makeRequest({ session: {} }));
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('throws 403 + records admin.access_denied when the principal has no privileges', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: { principal: principalWithPrivileges([]) },
method: 'GET',
originalUrl: '/api/admin/me',
}),
);
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/admin/me',
rolesHeld: [],
});
});
it('throws 403 + records admin.access_denied when the principal has non-admin privileges only', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: { principal: principalWithPrivileges(['Portal.Auditor']) },
method: 'POST',
originalUrl: '/api/admin/cms/pages',
}),
);
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'POST /api/admin/cms/pages',
rolesHeld: ['Portal.Auditor'],
});
});
it('returns true and does not audit when the principal carries Portal.Admin', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({ session: { principal: principalWithPrivileges([ADMIN_ROLE]) } }),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('returns true when admin is one of several privileges', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
principal: principalWithPrivileges(['Portal.Auditor', ADMIN_ROLE, 'Portal.DPO']),
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('propagates audit write failures (no audit ⇒ no action, per ADR-0013)', async () => {
const audit = {
adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
};
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(makeRequest({ session: { principal: principalWithPrivileges([]) } }));
// The 403 path goes through audit first; if audit throws, the
// guard surfaces the underlying error rather than the
// ForbiddenException — the caller sees a 500 and the audit
// invariant is preserved.
await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied');
});
describe('legacy-session bridge (sessions persisted before ADR-0025 landed)', () => {
it('synthesises a principal from session.user when session.principal is absent', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: [ADMIN_ROLE, 'Other.Role'],
},
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('denies a legacy session whose roles claim carries no Portal.* value', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: ['editor', 'auditor'],
},
},
}),
);
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
// The legacy bridge filters the raw `roles` claim down to
// `Portal.*` values for the audit row's `rolesHeld` field —
// `editor` and `auditor` are not privileges, so the held
// list is empty.
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/admin/me',
rolesHeld: [],
});
});
});
});
@@ -1,76 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal } from '../auth/principal-extractor';
/**
* Single Entra app role that gates the entire `/api/admin/*` surface
* per ADR-0020 §"Auth — `Portal.Admin` role claim". The value matches
* the `value` field declared on the app role in the Entra app
* registration manifest. Defined as a constant so spec assertions
* can refer to the same source of truth.
*/
export const ADMIN_ROLE = 'Portal.Admin';
/**
* `AdminRoleGuard` — enforces ADR-0020's role-based admin gate.
*
* Reads from `principal.privileges` per [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md)
* — the migration thinned the implementation but kept the
* `@RequireAdmin()` public API and the `admin.access_denied`
* audit event type unchanged.
*
* Contract
* --------
* - **No session → 401.** The user is not authenticated at all; the
* SPA should kick off the login flow. We do not audit anonymous
* probes here because the route family is not a target — any
* unauthenticated 401 is normal traffic the absolute-timeout
* middleware would surface anyway.
*
* - **Session but missing `Portal.Admin` privilege → 403 + audit.**
* The user *is* authenticated; they just are not authorised
* for admin. This is the privilege-escalation attempt audit
* signal — every denial lands in `audit.events` with
* `outcome=denied`, the attempted route as `subject`, and the
* `Portal.*` privileges the principal did hold in the payload's
* `rolesHeld` field (kept named `rolesHeld` for backward
* compatibility with the existing audit shape; the values are
* privileges per ADR-0025's renaming of the axis). Per
* ADR-0013 §"Blocking writes": no audit ⇒ no action.
*
* - **Session with `Portal.Admin` privilege → pass through.**
* Downstream controllers see `req.session.user` *and*
* `req.session.principal` populated and can rely on the gate
* having happened.
*/
@Injectable()
export class AdminRoleGuard implements CanActivate {
constructor(private readonly audit: AuditWriter) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const principal = readSessionPrincipal(req);
if (principal === null) {
throw new UnauthorizedException();
}
if (!principal.privileges.includes(ADMIN_ROLE)) {
await this.audit.adminAccessDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
rolesHeld: [...principal.privileges],
});
throw new ForbiddenException();
}
return true;
}
}
@@ -1,147 +0,0 @@
import { Test } from '@nestjs/testing';
import { PrismaService } from 'nestjs-prisma';
import { AdminUsersReader } from './admin-users-reader.service';
interface MockPrisma {
userDirectoryEntry: {
count: jest.Mock;
findMany: jest.Mock;
};
$transaction: jest.Mock;
}
function buildPrisma(opts?: { count?: number; items?: unknown[] }): MockPrisma {
const count = jest.fn().mockResolvedValue(opts?.count ?? 0);
const findMany = jest.fn().mockResolvedValue(opts?.items ?? []);
return {
userDirectoryEntry: { count, findMany },
// The reader calls $transaction with the promises returned by
// `count()` + `findMany()` — the operations have already fired
// by the time $transaction sees them. The mock just resolves
// them together so Prisma's tuple-return contract is honoured.
$transaction: jest
.fn()
.mockImplementation((promises: ReadonlyArray<Promise<unknown>>) => Promise.all(promises)),
};
}
async function createSubject(prisma: MockPrisma): Promise<AdminUsersReader> {
const moduleRef = await Test.createTestingModule({
providers: [AdminUsersReader, { provide: PrismaService, useValue: prisma }],
}).compile();
return moduleRef.get(AdminUsersReader);
}
const ROW = {
oid: 'user-oid',
tid: 'tenant-1',
audience: 'workforce',
username: 'jane.doe@apf.example',
displayName: 'Jane Doe',
firstSeenAt: new Date('2026-05-10T10:00:00.000Z'),
lastSeenAt: new Date('2026-05-14T08:30:00.000Z'),
};
describe('AdminUsersReader.findUsers', () => {
it('issues a COUNT + SELECT in a single Prisma transaction', async () => {
const prisma = buildPrisma({ count: 5, items: [ROW] });
const reader = await createSubject(prisma);
const page = await reader.findUsers({});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.userDirectoryEntry.count).toHaveBeenCalledTimes(1);
expect(prisma.userDirectoryEntry.findMany).toHaveBeenCalledTimes(1);
expect(page.total).toBe(5);
expect(page.items).toHaveLength(1);
});
it('projects rows into the SPA-facing shape (ISO timestamps)', async () => {
const prisma = buildPrisma({ items: [ROW] });
const reader = await createSubject(prisma);
const page = await reader.findUsers({});
expect(page.items[0]).toEqual({
oid: 'user-oid',
tid: 'tenant-1',
audience: 'workforce',
username: 'jane.doe@apf.example',
displayName: 'Jane Doe',
firstSeenAt: '2026-05-10T10:00:00.000Z',
lastSeenAt: '2026-05-14T08:30:00.000Z',
});
});
it('orders by last_seen_at DESC, oid ASC for deterministic pagination', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({});
const findManyArgs = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
orderBy: ReadonlyArray<Record<string, string>>;
};
expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]);
});
it('passes startsWith filter on username through to Prisma', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({ username: 'jane' });
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
where: { username?: { startsWith?: string } };
};
expect(args.where.username).toEqual({ startsWith: 'jane' });
});
it('passes case-insensitive contains filter on displayName through to Prisma', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({ displayName: 'doe' });
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
where: { displayName?: { contains?: string; mode?: string } };
};
expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' });
});
it('combines lastSeenAtFrom and lastSeenAtTo into a single gte/lt range filter', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({
lastSeenAtFrom: '2026-05-01T00:00:00.000Z',
lastSeenAtTo: '2026-05-14T23:59:59.999Z',
});
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
where: { lastSeenAt?: { gte?: Date; lt?: Date } };
};
expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date);
expect(args.where.lastSeenAt?.lt).toBeInstanceOf(Date);
expect(args.where.lastSeenAt?.gte?.toISOString()).toBe('2026-05-01T00:00:00.000Z');
expect(args.where.lastSeenAt?.lt?.toISOString()).toBe('2026-05-14T23:59:59.999Z');
});
it('applies default limit (50) and offset (0) when not provided', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
const page = await reader.findUsers({});
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
take: number;
skip: number;
};
expect(args.take).toBe(50);
expect(args.skip).toBe(0);
expect(page.limit).toBe(50);
expect(page.offset).toBe(0);
});
it('caps limit at MAX_LIMIT (200) even when a caller bypasses the DTO', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({ limit: 1000 });
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { take: number };
expect(args.take).toBe(200);
});
it('emits no WHERE clause when no filter is provided', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({});
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { where: object };
expect(args.where).toEqual({});
});
});
@@ -1,119 +0,0 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { PrismaService } from 'nestjs-prisma';
import { DEFAULT_LIMIT, MAX_LIMIT, type AdminUsersQueryDto } from './users-query.dto';
/**
* SPA-facing projection of a row from `public.user_directory_entries`.
* Mirrors the Prisma model but exposes ISO-string timestamps so the
* SPA never has to know about `Date` serialisation conventions.
*/
export interface AdminUserDto {
readonly oid: string;
readonly tid: string;
readonly audience: string;
readonly username: string;
readonly displayName: string;
readonly firstSeenAt: string;
readonly lastSeenAt: string;
}
export interface AdminUsersPage {
readonly items: readonly AdminUserDto[];
readonly total: number;
readonly limit: number;
readonly offset: number;
}
/**
* `AdminUsersReader` — query side of the
* `public.user_directory_entries` directory per ADR-0020 §"v1 scope
* — User list (read-only)". Drives the `GET /api/admin/users` admin
* endpoint.
*
* Uses Prisma's typed client directly — unlike `AuditReader`, no
* `SET LOCAL ROLE` is needed because `public.user_directory_entries`
* has no role-based privilege gate (it's a regular business table;
* the trust boundary is the `@RequireAdmin` guard on the controller).
*
* Default order: `last_seen_at DESC` — the "most recently active"
* sort the admin UI most often wants. Falls back to `oid ASC` as
* a tie-breaker so pagination stays deterministic when two rows
* share a timestamp (rare, but possible during a sign-in burst).
*/
@Injectable()
export class AdminUsersReader {
constructor(private readonly prisma: PrismaService) {}
async findUsers(filters: AdminUsersQueryDto): Promise<AdminUsersPage> {
const limit = Math.min(filters.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
const offset = filters.offset ?? 0;
const where = buildWhere(filters);
// Run COUNT + SELECT in a single Prisma transaction so the
// `total` reported to the SPA matches what's on the page —
// a concurrent sign-in landing between the two queries would
// otherwise produce an off-by-one between the count and the
// items.
const [total, items] = await this.prisma.$transaction([
this.prisma.userDirectoryEntry.count({ where }),
this.prisma.userDirectoryEntry.findMany({
where,
orderBy: [{ lastSeenAt: 'desc' }, { oid: 'asc' }],
take: limit,
skip: offset,
}),
]);
return {
items: items.map(toDto),
total,
limit,
offset,
};
}
}
function buildWhere(filters: AdminUsersQueryDto): Prisma.UserDirectoryEntryWhereInput {
const where: Prisma.UserDirectoryEntryWhereInput = {};
if (filters.username !== undefined) {
where.username = { startsWith: filters.username };
}
if (filters.displayName !== undefined) {
// Display names have natural casing variation ("Jane Doe" vs
// "jane doe") so the admin filter matches case-insensitively.
where.displayName = { contains: filters.displayName, mode: 'insensitive' };
}
if (filters.audience !== undefined) {
where.audience = filters.audience;
}
if (filters.lastSeenAtFrom !== undefined || filters.lastSeenAtTo !== undefined) {
where.lastSeenAt = {
...(filters.lastSeenAtFrom !== undefined ? { gte: new Date(filters.lastSeenAtFrom) } : {}),
...(filters.lastSeenAtTo !== undefined ? { lt: new Date(filters.lastSeenAtTo) } : {}),
};
}
return where;
}
interface UserRow {
oid: string;
tid: string;
audience: string;
username: string;
displayName: string;
firstSeenAt: Date;
lastSeenAt: Date;
}
function toDto(row: UserRow): AdminUserDto {
return {
oid: row.oid,
tid: row.tid,
audience: row.audience,
username: row.username,
displayName: row.displayName,
firstSeenAt: row.firstSeenAt.toISOString(),
lastSeenAt: row.lastSeenAt.toISOString(),
};
}
@@ -1,91 +0,0 @@
import type { Request } from 'express';
import type { AuditWriter } from '../audit/audit.service';
import { AdminUsersController } from './admin-users.controller';
import type { AdminUsersReader, AdminUsersPage } from './admin-users-reader.service';
import type { AdminUsersQueryDto } from './users-query.dto';
const PAGE: AdminUsersPage = {
items: [
{
oid: 'user-oid',
tid: 'tenant-1',
audience: 'workforce',
username: 'jane.doe@apf.example',
displayName: 'Jane Doe',
firstSeenAt: '2026-05-10T10:00:00.000Z',
lastSeenAt: '2026-05-14T08:30:00.000Z',
},
],
total: 137,
limit: 50,
offset: 0,
};
function makeReq(user?: { oid: string }): Request {
return { session: user !== undefined ? { user } : {} } as unknown as Request;
}
function makeFixture() {
const usersReader = { findUsers: jest.fn().mockResolvedValue(PAGE) };
const auditWriter = { adminUsersQuery: jest.fn().mockResolvedValue(undefined) };
const controller = new AdminUsersController(
usersReader as unknown as AdminUsersReader,
auditWriter as unknown as AuditWriter,
);
return { controller, usersReader, auditWriter };
}
describe('AdminUsersController.list', () => {
it('returns the page from AdminUsersReader', async () => {
const { controller } = makeFixture();
const page = await controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto);
expect(page).toBe(PAGE);
});
it('forwards the validated DTO to AdminUsersReader as-is', async () => {
const { controller, usersReader } = makeFixture();
const filters: AdminUsersQueryDto = {
username: 'jane',
audience: 'workforce',
lastSeenAtFrom: '2026-05-01T00:00:00.000Z',
limit: 25,
offset: 0,
};
await controller.list(makeReq({ oid: 'admin-oid' }), filters);
expect(usersReader.findUsers).toHaveBeenCalledWith(filters);
});
it('emits admin.users.query with filters + result count', async () => {
const { controller, auditWriter } = makeFixture();
const filters: AdminUsersQueryDto = { username: 'jane', limit: 50 };
await controller.list(makeReq({ oid: 'admin-oid' }), filters);
expect(auditWriter.adminUsersQuery).toHaveBeenCalledWith({
actor: { oid: 'admin-oid' },
filters: { username: 'jane', limit: 50 },
resultCount: PAGE.items.length,
});
});
it('captures an empty filter object verbatim', async () => {
const { controller, auditWriter } = makeFixture();
await controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto);
expect(auditWriter.adminUsersQuery).toHaveBeenCalledWith(
expect.objectContaining({ filters: {} }),
);
});
it('still returns the page when no session.user (defensive — guard normally catches)', async () => {
const { controller, auditWriter } = makeFixture();
const page = await controller.list(makeReq(), {} as AdminUsersQueryDto);
expect(page).toBe(PAGE);
expect(auditWriter.adminUsersQuery).not.toHaveBeenCalled();
});
it('propagates audit write failures (blocking per ADR-0013)', async () => {
const { controller, auditWriter } = makeFixture();
auditWriter.adminUsersQuery.mockRejectedValueOnce(new Error('audit_writer denied'));
await expect(
controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto),
).rejects.toThrow('audit_writer denied');
});
});

Some files were not shown because too many files have changed in this diff Show More