feat(web): Angular skeleton with PKCE auth, routes and i18n #50

Merged
patrick merged 2 commits from feature/web-skeleton into main 2026-09-11 10:24:21 +00:00
Owner

What this delivers

PH0.5 (docs/12-implementation-plan.md, Wave 0a) — an Angular 22 workspace (standalone, signals, zoneless) under frontend/ with every frontend dependency from docs/07-architecture.md, a Keycloak PKCE login flow, a complete route table, and the Transloco i18n plumbing.

  • Dependencies: Optimus UI (+ @angular/cdk, @openng/optimus-ui-themes), NgRx SignalStore (@ngrx/signals), MapLibre GL, keycloak-js, @jsverse/transloco, openapi-typescript, angular-eslint.
  • Auth (core/auth/): keycloak-js PKCE (onLoad: 'login-required'), an AuthService exposing roles/username/locale as signals, an HTTP interceptor that attaches a bearer token only to Polaris API calls, and a roleGuard() factory applied to every feature route.
  • i18n (core/i18n/): a TranslocoLoader that resolves each feature's own i18n/{en,nl}.json (docs/07-architecture.md frontend layout) plus the shell scope from core/i18n/; locale resolution localStorage → token locale claim → browser → en; a language switcher; a custom ESLint rule (tools/eslint-rules/no-template-literal-text.cjs) that rejects raw template text and literal translatable attributes; tools/i18n-parity.mjs checking every scope has matching en/nl keys.
  • Routes/nav (app.routes.ts, core/nav.ts): one lazy route per feature in docs/06-ux.md — 13 features total, matching docs/07-architecture.md's feature-directory list (dashboard plan jobs vehicles sites crews today feedback catalogue ingest audit settings admin-crud) — each role-guarded and pointing at a stub NotBuiltPage until its own track builds the real page. A spec (app.routes.spec.ts) asserts routes and nav entries never drift apart.
  • Shell (core/layout/shell.component): top nav filtered by role, language switcher, and the "hello" panel showing the signed-in user and their realm roles from the token — this is what PH0's "Proves" line refers to (LDAP group → Keycloak role → UI).
  • API client generation: npm run api:gen (tools/api-gen.mjs) generates the typed client from backend/openapi.json into the git-ignored core/api/generated/. backend/openapi.json is delivered by PH1.7, which hasn't merged yet — the script is tolerant of that, printing a note and exiting 0 rather than failing.
  • frontend/Dockerfile: multi-stage node build → nginx, a self-contained docker/nginx.conf (SPA fallback, /api proxy to api:8000 with lazy DNS resolution so the container doesn't crash-loop if api isn't up yet), and a docker-entrypoint.d script that renders config.js from POLARIS_* env vars at container start — one image, configured per environment.
  • frontend/.npmrc: legacy-peer-deps=true. openapi-typescript's typescript@^5.x peer range hasn't caught up with Angular 22's typescript@~6.0.2 yet; it only consumes the generated TS AST so this is safe. Without it, npm ci fails outright on a peer conflict.

Verified locally

  • npm ci && npm run build — green (production build, ~101 kB gzip initial).
  • npm run lint (including the custom no-literal-text rule) — green.
  • npm test (vitest, 5 files / 12 tests) — green.
  • npm run i18n:parity — green.
  • docker build + docker run — SPA served, config.js correctly rendered from POLARIS_* env vars, SPA fallback and /api proxy both verified.

Verified live, against a real Keycloak/LDAP

Brought up PR #49's (feature/infra-compose-base) compose stack in a scratch clone (not merged into this branch — infra/** stays out of this PR), spun it up, drove this PR's actual frontend through a headless-browser PKCE login (Playwright/Chromium) for all five LDAP users, then tore the stack down. This caught and fixed two real bugs that ng build/eslint/vitest never exercised:

  1. provideAppInitializer(bootstrapApp()) called bootstrapApp() immediately at module-eval time, before Angular's injector existed — inject(AuthService) threw NG0203 on every load, so login never started. Fixed by passing the function reference itself.
  2. ScopedTranslocoLoader assumed Transloco's loader callback gets a bare language code; for a scoped request it's actually "<scope>/<lang>", so every scoped translation fetch 404'd. Fixed by stripping the scope prefix.

Also fixed environment.development.ts's guessed Keycloak port (8081, the nginx gateway) to the real one (8080), now that infra's compose file exists to check against.

Results:

OK   admin        hello='Hello, admin'      roleChip='Admin'      nav=12 entries
OK   planner      hello='Hello, planner'    roleChip='Planner'    nav=8 entries
OK   technician   hello='Hello, technician' roleChip='Technician' nav=1 entry
OK   viewer       hello='Hello, viewer'     roleChip='Viewer'     nav=2 entries
OK   ingest       hello='Hello, ingest'     roleChip='Ingest'     nav=0 entries

OK   viewer -> /settings  redirected to /forbidden (admin-only)
OK   viewer -> /jobs      redirected to /forbidden (planner/admin-only)
OK   viewer -> /plan      allowed (viewer has read access)

OK   language switch: nav label 'Vehicles' -> 'Voertuigen' immediately,
     persists as 'nl' across a full page reload via localStorage

Nav counts match docs/02-roles-and-glossary.md's permission matrix exactly.

Acceptance criteria (issue #5)

  • Login as each LDAP user shows that user's role on the hello page; role guards block routes the role may not see — verified live, see above
  • Language switch persists (localStorage → token locale → browser → en) and every shell string has both nl and en keys; the key-parity script passes
  • app.routes.ts and core/nav.ts list every feature from docs/06-ux.md as a lazy route to a placeholder
  • npm run api:gen produces the client from backend/openapi.json into a git-ignored folder
  • eslint (incl. the no-literal rule), vitest and production build are green; frontend Dockerfile builds

Closes #5

🤖 Generated with Claude Code

https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX

## What this delivers PH0.5 (docs/12-implementation-plan.md, Wave 0a) — an Angular 22 workspace (standalone, signals, zoneless) under `frontend/` with every frontend dependency from docs/07-architecture.md, a Keycloak PKCE login flow, a complete route table, and the Transloco i18n plumbing. - **Dependencies**: Optimus UI (+ `@angular/cdk`, `@openng/optimus-ui-themes`), NgRx SignalStore (`@ngrx/signals`), MapLibre GL, `keycloak-js`, `@jsverse/transloco`, `openapi-typescript`, `angular-eslint`. - **Auth** (`core/auth/`): `keycloak-js` PKCE (`onLoad: 'login-required'`), an `AuthService` exposing roles/username/locale as signals, an HTTP interceptor that attaches a bearer token only to Polaris API calls, and a `roleGuard()` factory applied to every feature route. - **i18n** (`core/i18n/`): a `TranslocoLoader` that resolves each feature's own `i18n/{en,nl}.json` (docs/07-architecture.md frontend layout) plus the shell scope from `core/i18n/`; locale resolution `localStorage` → token `locale` claim → browser → `en`; a language switcher; a **custom ESLint rule** (`tools/eslint-rules/no-template-literal-text.cjs`) that rejects raw template text and literal translatable attributes; `tools/i18n-parity.mjs` checking every scope has matching `en`/`nl` keys. - **Routes/nav** (`app.routes.ts`, `core/nav.ts`): one lazy route per feature in docs/06-ux.md — 13 features total, matching docs/07-architecture.md's feature-directory list (`dashboard plan jobs vehicles sites crews today feedback catalogue ingest audit settings admin-crud`) — each role-guarded and pointing at a stub `NotBuiltPage` until its own track builds the real page. A spec (`app.routes.spec.ts`) asserts routes and nav entries never drift apart. - **Shell** (`core/layout/shell.component`): top nav filtered by role, language switcher, and the "hello" panel showing the signed-in user and their realm roles from the token — this is what PH0's "Proves" line refers to (LDAP group → Keycloak role → UI). - **API client generation**: `npm run api:gen` (`tools/api-gen.mjs`) generates the typed client from `backend/openapi.json` into the git-ignored `core/api/generated/`. `backend/openapi.json` is delivered by PH1.7, which hasn't merged yet — the script is tolerant of that, printing a note and exiting `0` rather than failing. - **`frontend/Dockerfile`**: multi-stage `node` build → `nginx`, a self-contained `docker/nginx.conf` (SPA fallback, `/api` proxy to `api:8000` with lazy DNS resolution so the container doesn't crash-loop if `api` isn't up yet), and a `docker-entrypoint.d` script that renders `config.js` from `POLARIS_*` env vars at container start — one image, configured per environment. - **`frontend/.npmrc`**: `legacy-peer-deps=true`. `openapi-typescript`'s `typescript@^5.x` peer range hasn't caught up with Angular 22's `typescript@~6.0.2` yet; it only consumes the generated TS AST so this is safe. Without it, `npm ci` fails outright on a peer conflict. ## Verified locally - `npm ci && npm run build` — green (production build, ~101 kB gzip initial). - `npm run lint` (including the custom no-literal-text rule) — green. - `npm test` (vitest, 5 files / 12 tests) — green. - `npm run i18n:parity` — green. - `docker build` + `docker run` — SPA served, `config.js` correctly rendered from `POLARIS_*` env vars, SPA fallback and `/api` proxy both verified. ## Verified live, against a real Keycloak/LDAP Brought up PR #49's (`feature/infra-compose-base`) compose stack in a scratch clone (not merged into this branch — `infra/**` stays out of this PR), spun it up, drove this PR's actual frontend through a headless-browser PKCE login (Playwright/Chromium) for all five LDAP users, then tore the stack down. This caught and fixed **two real bugs** that `ng build`/eslint/vitest never exercised: 1. `provideAppInitializer(bootstrapApp())` called `bootstrapApp()` immediately at module-eval time, before Angular's injector existed — `inject(AuthService)` threw `NG0203` on every load, so login never started. Fixed by passing the function reference itself. 2. `ScopedTranslocoLoader` assumed Transloco's loader callback gets a bare language code; for a scoped request it's actually `"<scope>/<lang>"`, so every scoped translation fetch 404'd. Fixed by stripping the scope prefix. Also fixed `environment.development.ts`'s guessed Keycloak port (8081, the nginx gateway) to the real one (8080), now that infra's compose file exists to check against. Results: ``` OK admin hello='Hello, admin' roleChip='Admin' nav=12 entries OK planner hello='Hello, planner' roleChip='Planner' nav=8 entries OK technician hello='Hello, technician' roleChip='Technician' nav=1 entry OK viewer hello='Hello, viewer' roleChip='Viewer' nav=2 entries OK ingest hello='Hello, ingest' roleChip='Ingest' nav=0 entries OK viewer -> /settings redirected to /forbidden (admin-only) OK viewer -> /jobs redirected to /forbidden (planner/admin-only) OK viewer -> /plan allowed (viewer has read access) OK language switch: nav label 'Vehicles' -> 'Voertuigen' immediately, persists as 'nl' across a full page reload via localStorage ``` Nav counts match docs/02-roles-and-glossary.md's permission matrix exactly. ## Acceptance criteria (issue #5) - [x] Login as each LDAP user shows that user's role on the hello page; role guards block routes the role may not see — verified live, see above - [x] Language switch persists (`localStorage` → token `locale` → browser → `en`) and every shell string has both `nl` and `en` keys; the key-parity script passes - [x] `app.routes.ts` and `core/nav.ts` list every feature from docs/06-ux.md as a lazy route to a placeholder - [x] `npm run api:gen` produces the client from `backend/openapi.json` into a git-ignored folder - [x] eslint (incl. the no-literal rule), vitest and production build are green; frontend Dockerfile builds Closes #5 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX
PH0.5 (docs/12-implementation-plan.md, Wave 0a). Angular 22 workspace
(standalone, signals, zoneless) under frontend/ with every frontend
dependency from docs/07-architecture.md: Optimus UI (+cdk, themes),
NgRx SignalStore, MapLibre GL, keycloak-js, Transloco, openapi-typescript.

- core/auth: keycloak-js PKCE login (onLoad: login-required), an
  AuthService exposing roles/username/locale as signals, an HTTP
  interceptor that attaches a fresh bearer token to API calls only, and
  a roleGuard() factory used on every feature route.
- core/i18n: a Transloco loader resolving each feature's own
  i18n/{en,nl}.json (docs/07-architecture.md frontend layout) plus the
  shell scope in core/i18n; locale resolution localStorage -> token
  `locale` claim -> browser -> en; a language switcher; a custom ESLint
  rule (tools/eslint-rules/no-template-literal-text.cjs) that rejects
  raw text/attributes in templates; tools/i18n-parity.mjs checking
  every scope has matching en/nl keys.
- core/nav.ts + app.routes.ts: one lazy route per feature in
  docs/06-ux.md (13 features, matching the architecture doc's feature
  list), each a role-guarded stub NotBuiltPage until its track builds
  the real page; a parity spec asserts routes and nav never drift.
- core/layout/shell.component: top nav filtered by role, language
  switcher, and the "hello" panel showing the signed-in user's roles
  from the token (proves LDAP group -> Keycloak role -> UI, per PH0's
  "Proves" line).
- tools/api-gen.mjs (npm run api:gen): generates the typed client from
  backend/openapi.json into git-ignored core/api/generated/; tolerant
  of the spec not existing yet (delivered in PH1.7) — prints a note and
  exits 0 rather than failing the build.
- frontend/Dockerfile: multi-stage node build -> nginx, a
  self-contained nginx.conf (SPA fallback, /api proxy to `api:8000`,
  lazy DNS resolution so the container doesn't crash-loop if `api`
  isn't up yet), and a docker-entrypoint.d script that renders
  config.js from POLARIS_* env vars at container start so one image
  works in every environment.
- frontend/.npmrc: legacy-peer-deps=true, because openapi-typescript's
  `typescript@^5.x` peer range hasn't caught up with Angular 22's
  `typescript@~6.0.2` yet; it only reads the generated AST so this is
  safe. Without it `npm ci` fails outright.

Verified locally: npm ci && npm run build/lint/test/i18n:parity all
green, and `docker build` + `docker run` serve the SPA with config.js
correctly rendered from env vars.

Not verified in this sandbox (no Keycloak/LDAP/compose available
here): an actual login round-trip against the five LDAP users. The
PKCE flow, role extraction and guards are implemented and covered by
unit tests against a mocked token; end-to-end login is exercised by
PH0.6's compose smoke test per docs/10-dev-workflow.md.

Closes #5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX
Brought up the real Keycloak/LDAP/compose stack from PR #49
(feature/infra-compose-base, not merged into this branch — checked out
separately in a scratch clone, spun up there, torn down after) and drove
the actual PH0.5 frontend through a headless-browser PKCE login for all
five LDAP users plus role-guard checks. That live test caught two bugs
neither the build, lint, nor unit tests exercised:

- app.config.ts passed `bootstrapApp()` (called) instead of `bootstrapApp`
  (the reference) to provideAppInitializer. That runs the function at
  module-eval time, before bootstrapApplication() has created an
  injector, so `inject(AuthService)` throws NG0203 on every page load —
  login never even started. Fixed by making bootstrapApp itself the
  function Angular calls in its injection context.
- ScopedTranslocoLoader treated Transloco's first getTranslation()
  argument as a bare language code; for a scoped load it's actually
  `"<scope>/<lang>"` (confirmed against the library source), so every
  scoped fetch 404'd (e.g. `core/i18n/shell/en.json` instead of
  `core/i18n/en.json`) and the shell rendered with empty translations.
  Fixed by stripping the `<scope>/` prefix using `data.scope`.

Also fixes environment.development.ts's Keycloak URL: guessed at
`localhost:8081` (PH0.5 was built before infra's compose file existed);
the real infra/compose/docker-compose.yml exposes Keycloak on 8080 and
8081 is the unrelated nginx gateway.

Verified against the live stack (scripted PKCE + Playwright/Chromium):
all five LDAP users (admin/planner/technician/viewer/ingest) log in and
the hello panel shows exactly their role and role-filtered nav (12/8/1/2/0
entries respectively); roleGuard redirects a viewer hitting /settings or
/jobs to /forbidden and leaves an allowed route alone; the language
switcher flips nav labels live and persists across a reload via
localStorage. `npm run build/lint/test` all still green.

Refs #5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX
patrick deleted branch feature/web-skeleton 2026-09-11 10:24:21 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
patrick/Polaris!50
No description provided.