# M08 — UI Scaffold & Auth Flows

> Fresh Claude Code agent prompt. M07 must be complete and committed.
> Estimated effort: large. This milestone establishes the entire UI baseline.

## Mission

Build the `ui` container's foundation: Slim app, base layout (Tailwind + dark mode + sidebar/topnav), session manager, CSRF middleware, ApiClient with retry, OIDC redirect/callback flow, local admin login form, logout. After this milestone, a user can sign in via OIDC against a test tenant or as local admin, and a `/app/me` page renders showing the user's identity. **Page content beyond `/app/me` lands in M09.**

## Before you start

1. Verify M07:
   ```bash
   git log --oneline -7
   cd api && composer test && composer stan && cd ..
   cd ui && composer test && composer stan && cd ..
   ```
2. Read `SPEC.md` §2 (UI tech stack), §6 (Auth API endpoints — `/api/v1/auth/users/upsert-{oidc,local}`, `/api/v1/admin/me`), §7 (UI Container — full section, especially "Identity resolution flow"), §8 (Authentication — UI-side parts).
3. Set up an Entra test tenant or use a workforce/personal account if you can. Document the setup steps you used in `doc/oidc.md` (this is allowed even though M13 owns most docs — auth setup is a prerequisite for testing this milestone).

## Tasks

### 1. Base UI infrastructure

In `ui/src/App/`:
- `Bootstrap.php` — boots Slim, registers middlewares, registers routes.
- `Container.php` — DI bindings. Bind a `GuzzleHttp\Client` configured with `API_BASE_URL` and 5s default timeout.
- `Routes.php` — declared routes; auth-required routes mounted under `/app/*`.

In `ui/src/Http/`:
- `JsonExceptionHandler.php` — catches uncaught exceptions, renders a friendly Twig error page (not raw JSON; this is a UI), logs the full exception. Distinguish 4xx vs 5xx in the rendered template.
- `CsrfMiddleware.php` — generates a per-session CSRF token, validates on POST/PUT/PATCH/DELETE, exposes the token to Twig via a global. Use a constant-time compare.
- `FlashMessageMiddleware.php` — pulls flash messages from session and exposes to Twig.

### 2. Session management

In `ui/src/Auth/`:
- `SessionManager.php` — wraps PHP native sessions. Methods: `startSession()`, `setUser(int $userId, string $displayName, string $role, ?string $email)`, `getUser(): ?UserContext`, `clear()`, `regenerateId()` (call after auth success).
- `UserContext.php` — value object with the cached fields.
- Sessions: file-based, inside the container. Cookie: name `irdb_session`, `HttpOnly`, `SameSite=Lax`, `Secure` when `APP_ENV=production`.
- Session lifetime: 8 hours of inactivity; absolute max 24 hours.

### 3. ApiClient

In `ui/src/ApiClient/`:
- `ApiClient.php` — wraps Guzzle. Auto-attaches:
  - `Authorization: Bearer <UI_SERVICE_TOKEN>`.
  - `X-Acting-User-Id: <session.user_id>` if a user is in the session.
  - `Accept: application/json`.
  - User agent: `irdb-ui/<version>`.
- Automatic retry (1 retry on connection errors and 5xx; no retry on 4xx).
- Maps non-2xx responses to typed exceptions: `ApiAuthException` (401/403), `ApiValidationException` (400/422 — carries field errors), `ApiNotFoundException` (404), `ApiServerException` (5xx), `ApiUnreachableException` (network/timeout).
- Subclients per endpoint group:
  - `AuthClient` — `upsertOidc(...)`, `upsertLocal(...)`.
  - `AdminClient` — `getMe()`, plus stubs for endpoints used in M09–M12. **Don't implement subclient methods you don't yet need; M09+ adds them as needed.**

DTOs in `ui/src/ApiClient/DTOs/` — small classes mirroring API response shapes (`UserDto`, `PolicyDto`, etc.). Strict types; no array soup leaking into controllers.

### 4. OIDC flow

In `ui/src/Auth/OidcController.php`:

- `GET /login/oidc` — initiates flow. Generates state + code-verifier + nonce; stores in session; redirects to Entra authorize endpoint with PKCE.
- `GET /oidc/callback`:
  1. Validates state.
  2. Exchanges code for tokens via the OIDC client.
  3. Validates the ID token (signature, issuer, audience, expiry, nonce).
  4. Extracts `sub`, `email` (or `preferred_username` if email absent), `name`, `groups` (array of group object IDs).
  5. Calls `AuthClient::upsertOidc($sub, $email, $displayName, $groups)`.
  6. If the API returns a user with role `none` (or however your `OIDC_DEFAULT_ROLE=none` case surfaces), redirect to a "no access" page rather than logging in.
  7. Calls `SessionManager::regenerateId()`, then `setUser(...)`.
  8. Redirects to `/app/me` (the session manager remembers a `next` URL if one was set pre-auth).

Use `jumbojett/openid-connect-php`. Configure scopes: `openid profile email`. The `groups` claim must already be present in the ID token from Entra; document that in `doc/oidc.md`.

### 5. Local admin login

In `ui/src/Auth/LocalLoginController.php`:

- `GET  /login` — renders the login form.
- `POST /login/local`:
  1. CSRF check (handled by middleware, but verify it's wired).
  2. Validate username matches `LOCAL_ADMIN_USERNAME`.
  3. `password_verify` against `LOCAL_ADMIN_PASSWORD_HASH`.
  4. Throttle: track failed attempts in session + a small in-memory backoff. After 5 failures: 30-second lockout. (Full brute-force protection is M14; this is the basic version.)
  5. On success: `AuthClient::upsertLocal($username)`, regenerate session, `setUser(...)`, redirect to `/app/me`.
  6. On failure: flash error, redirect back to `/login`.
- Hide local sign-in entirely if `LOCAL_ADMIN_ENABLED=false`.

### 6. Logout

- `POST /logout` — clears session, redirects to `/login`. CSRF-protected.

### 7. Pages

In `ui/resources/views/`:

- `layout.twig` — full layout: top nav (logo, search-box stub, dark-mode toggle button, user menu with logout), sidebar (placeholder links to Dashboard, IPs, Subnets, Allowlist, Policies, Reporters, Consumers, Tokens, Categories, Audit, Settings — these aren't built yet, but the nav structure is).
- `pages/login.twig` — clean centered card. "Sign in with Microsoft" primary button (only if `OIDC_ENABLED=true`), "Local sign-in" collapsed/hidden behind a link (only if `LOCAL_ADMIN_ENABLED=true`).
- `pages/me.twig` — `/app/me`: shows user_id, email, display_name, role, source ("oidc" / "local"). Includes a "Logout" button (POSTs).
- `pages/no-access.twig` — for OIDC users who land here without a role grant.
- `pages/error.twig` — generic error template.
- `partials/topnav.twig`, `partials/sidebar.twig`, `partials/flash.twig`, `partials/csrf.twig`.

Tailwind: dark mode via `class="dark"` toggled by JS on `<html>`. Persist to `localStorage` (key `irdb-theme`). Default to system preference on first visit. Use `prefers-color-scheme` media query as fallback.

Alpine for the dark-mode toggle and the user menu dropdown. htmx not strictly needed yet but the pattern should be established for M09's tables.

### 8. Healthz update

`GET /healthz` in the ui:
- Returns `200` always (unless the ui itself is broken).
- Body includes `api_reachable` (boolean, last status) and `last_api_check_at` (ISO 8601 of most recent successful HEAD on `<API_BASE_URL>/healthz`).
- A background ticker is overkill — just remember the most recent ApiClient call's success/failure in a singleton service. If no API call has happened yet, both fields are null.

### 9. Configuration

Read these env vars (all UI-side, per SPEC §9):
- `OIDC_ENABLED`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI`.
- `LOCAL_ADMIN_ENABLED`, `LOCAL_ADMIN_USERNAME`, `LOCAL_ADMIN_PASSWORD_HASH`.
- `UI_SERVICE_TOKEN`, `API_BASE_URL`.
- `UI_SECRET`, `PUBLIC_URL`, `APP_ENV`, `LOG_LEVEL`.

Validate at startup: log a clear error and exit non-zero if `UI_SERVICE_TOKEN` or `API_BASE_URL` are missing. If both `OIDC_ENABLED=false` and `LOCAL_ADMIN_ENABLED=false`, exit non-zero (no way to log in).

### 10. OIDC documentation stub

Create `doc/oidc.md` with the steps you actually used to set up Entra ID for testing:
- App registration creation
- Redirect URI
- Client secret
- API permissions (and consent)
- Group claim configuration in token configuration / app manifest
- Test user assignment

Keep it factual; M13 will polish.

## Implementation notes

- **`upsertOidc` failure modes**: if the API returns 5xx, render an error page with retry. If it returns the user with role `viewer` and you're redirecting to `/app/me` (where they can see their identity but nothing else yet), that's correct behavior. The "no role" case (when `OIDC_DEFAULT_ROLE=none` and no group matches) needs a clear "no access — contact admin" page.
- **Session fixation**: regenerate session ID on every auth-state change (login success, logout). Sessions before login should not carry over.
- **CSRF on logout**: yes, even logout. Otherwise CSRF can log users out unexpectedly.
- **htmx + CSRF**: htmx requests must include the CSRF token. Use the standard pattern of a meta tag in `layout.twig` and an htmx config that pulls it.
- **Dark mode FOUC**: prevent flash by inlining a tiny script in `<head>` that reads `localStorage` and applies the class before the body renders.
- **API unreachable**: when ApiClient throws `ApiUnreachableException`, the relevant page should render a friendly degraded state (sidebar still visible, content area shows "API unreachable; retrying in 30s"). Don't crash the whole UI.
- **Tests**:
  - Unit: SessionManager, ApiClient exception mapping, CSRF middleware.
  - Integration: spin up the ui Slim app with a mocked ApiClient. Verify login flows, redirects, session set, CSRF rejection.
  - You can mock OIDC; full OIDC integration tests against a real tenant are out of scope (manual verification suffices).

## Out of scope (DO NOT)

- Any UI page beyond `/login`, `/oidc/callback`, `/app/me`, `/no-access`, errors. M09 onward.
- Calling admin endpoints other than `/api/v1/admin/me`. M09+.
- Token entry list, IP search, dashboard charts, etc. M09+.
- Audit display. M12.
- Settings page with job triggers. M12.
- Brute-force lockout beyond the basic 5-fail/30s. Full version M14.
- Rate-limiting the UI. M14.
- New api endpoints. (If you find you need one, stop and reconsider — likely M09+ work creeping in.)
- Touching `api/` code at all. UI-only milestone.

## Acceptance

```bash
cd ui && composer cs && composer stan && composer test && cd ..
cd ui && npm ci && npm run build && cd ..

docker compose down -v
cp .env.example .env
# Set: UI_SERVICE_TOKEN matches between containers
# Set: LOCAL_ADMIN_ENABLED=true, LOCAL_ADMIN_USERNAME=admin
# Generate hash: php -r 'echo password_hash("test1234", PASSWORD_ARGON2ID);'
# Set: LOCAL_ADMIN_PASSWORD_HASH=<that hash>
# OIDC: leave with placeholder issuer/client_id/secret unless you have a tenant ready
docker compose up -d
sleep 20

# UI returns login page when not authenticated
curl -s http://localhost:8080/ -L | grep -q "Sign in"

# Healthz works on UI
curl -sf http://localhost:8080/healthz | grep -q '"status":"ok"'

# /app/me unauthenticated redirects to /login
test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/app/me)" = "302"

# Local admin login flow (CSRF-aware)
COOKIE_JAR=$(mktemp)
# 1. GET /login to obtain a session + CSRF token
LOGIN_PAGE=$(curl -s -c $COOKIE_JAR http://localhost:8080/login)
CSRF=$(echo "$LOGIN_PAGE" | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
[ -n "$CSRF" ]
# 2. POST credentials
curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
  -d "csrf_token=$CSRF&username=admin&password=test1234" \
  http://localhost:8080/login/local -L -o /tmp/me_response.html
grep -q "admin@local" /tmp/me_response.html || grep -q '"role":"admin"' /tmp/me_response.html || grep -qi "local admin" /tmp/me_response.html

# Wrong password is rejected
COOKIE_JAR2=$(mktemp)
LOGIN_PAGE=$(curl -s -c $COOKIE_JAR2 http://localhost:8080/login)
CSRF=$(echo "$LOGIN_PAGE" | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
RESP=$(curl -s -b $COOKIE_JAR2 -c $COOKIE_JAR2 -X POST \
  -d "csrf_token=$CSRF&username=admin&password=WRONG" \
  http://localhost:8080/login/local -L)
echo "$RESP" | grep -qi "invalid\|incorrect\|failed"

# CSRF without token is rejected
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST \
  -d "username=admin&password=test1234" \
  http://localhost:8080/login/local)" = "403"

# Logout
curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
  -d "csrf_token=$CSRF" \
  http://localhost:8080/logout -L > /dev/null
# After logout, /app/me redirects to /login
test "$(curl -s -b $COOKIE_JAR -o /dev/null -w '%{http_code}' http://localhost:8080/app/me)" = "302"

docker compose down -v
```

OIDC flow against a real tenant: manual verification. Document the test in `PROGRESS.md`.

## Handoff

1. Commit:
   ```
   feat(M08): ui scaffold, OIDC + local admin auth, session, ApiClient

   - Slim+Twig+Tailwind base with dark-mode toggle and sidebar/topnav layout
   - ApiClient with auto Bearer + X-Acting-User-Id, retry, typed exceptions
   - OIDC code-flow with PKCE; ID token validation; upsert-oidc → session
   - Local admin login with Argon2id verify, basic 5-fail/30s throttle
   - logout, CSRF-protected; CSRF middleware globally
   - /app/me renders user identity, /no-access for unmapped OIDC users
   - doc/oidc.md drafted with Entra setup steps
   ```

2. Append to `PROGRESS.md`:
   ```markdown
   ## M08 — UI scaffold & auth (done)

   **Built:** UI base, both auth paths, sessions, ApiClient.

   **Notes for next milestone:**
   - AdminClient has only `getMe()`; M09 adds methods for IP search, IP detail, etc.
   - The ApiClient's exception types are stable; M09+ catches them in controllers.
   - Sidebar links exist but most lead to "not implemented" placeholders. M09–M12 fills them.
   - dark mode persistence: `localStorage.irdb-theme = 'dark' | 'light'`.
   - M14 will replace the basic 5/30 throttle with a full brute-force lockout.

   **Manual verification:**
   - OIDC flow against [tenant name / "test tenant configured at ..."]: succeeded for users in groups [...].
   - Tested role mapping by [...].

   **Deviations from SPEC:** none.
   **Added dependencies:** [list, e.g. jumbojett/openid-connect-php was already in SPEC §2].
   ```

3. **Stop.** Do not start M09.
