Fresh Claude Code agent prompt. M07 must be complete and committed. Estimated effort: large. This milestone establishes the entire UI baseline.
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.
Verify M07:
git log --oneline -7
cd api && composer test && composer stan && cd ..
cd ui && composer test && composer stan && cd ..
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).
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).
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.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.irdb_session, HttpOnly, SameSite=Lax, Secure when APP_ENV=production.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.irdb-ui/<version>.ApiAuthException (401/403), ApiValidationException (400/422 — carries field errors), ApiNotFoundException (404), ApiServerException (5xx), ApiUnreachableException (network/timeout).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.
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:
sub, email (or preferred_username if email absent), name, groups (array of group object IDs).AuthClient::upsertOidc($sub, $email, $displayName, $groups).none (or however your OIDC_DEFAULT_ROLE=none case surfaces), redirect to a "no access" page rather than logging in.SessionManager::regenerateId(), then setUser(...)./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.
In ui/src/Auth/LocalLoginController.php:
GET /login — renders the login form.POST /login/local:
LOCAL_ADMIN_USERNAME.password_verify against LOCAL_ADMIN_PASSWORD_HASH.AuthClient::upsertLocal($username), regenerate session, setUser(...), redirect to /app/me./login.LOCAL_ADMIN_ENABLED=false.POST /logout — clears session, redirects to /login. CSRF-protected.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.
GET /healthz in the ui:
200 always (unless the ui itself is broken).api_reachable (boolean, last status) and last_api_check_at (ISO 8601 of most recent successful HEAD on <API_BASE_URL>/healthz).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).
Create doc/oidc.md with the steps you actually used to set up Entra ID for testing:
Keep it factual; M13 will polish.
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.layout.twig and an htmx config that pulls it.<head> that reads localStorage and applies the class before the body renders.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./login, /oidc/callback, /app/me, /no-access, errors. M09 onward./api/v1/admin/me. M09+.api/ code at all. UI-only milestone.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.
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
Append to PROGRESS.md:
## 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].
Stop. Do not start M09.