# Auth Flow (UI) The Galaxy UI client uses a two-step e-mail-code login: the user submits an e-mail address, receives a six-digit code by mail, then submits the code together with a freshly generated Ed25519 public key. The gateway returns a `device_session_id`, which the client persists for subsequent visits. This doc covers the UI side; the backend behaviour, throttling, and account-creation rules are authoritative in [`docs/FUNCTIONAL.md` §1](../../docs/FUNCTIONAL.md). ## Surface - `ui/frontend/src/api/auth.ts` — `sendEmailCode`, `confirmEmailCode`, and the `AuthError` taxonomy. - `ui/frontend/src/lib/session-store.svelte.ts` — singleton reactive state (`status`, `keypair`, `deviceSessionId`) plus `init`, `signIn`, `signOut`. - `ui/frontend/src/lib/revocation-watcher.ts` — minimal `SubscribeEvents` watcher that triggers `signOut("revoked")` on any non-aborted stream termination. - `ui/frontend/src/routes/login/+page.svelte` — two-step form. - `ui/frontend/src/routes/lobby/+page.svelte` — placeholder lobby that issues the first authenticated `user.account.get`. - `ui/frontend/src/routes/+layout.svelte` — route guard plus the browser-not-supported blocker. ## State machine (`SessionStatus`) ```text init() │ ▼ ┌─────────────┐ │ loading │ └──┬───────┬──┘ │ │ │ ▼ │ Ed25519 missing → unsupported ▼ device id? ┌────┴────┐ │ │ ▼ ▼ anonymous authenticated │ │ signIn signOut(*) └────────►│ ▼ anonymous ``` `signOut("revoked")` shares the same observable end state as `signOut("user")`; the reason exists only for telemetry. Both trigger the layout effect's `anonymous → /login` redirect. ## UX states and error mapping The send-email-code endpoint deliberately returns a uniform response shape regardless of whether the address is new, existing, throttled, or rate-limited (see [`docs/FUNCTIONAL.md` §1.2](../../docs/FUNCTIONAL.md)). The UI therefore treats every 200 the same and never tries to distinguish those branches. | Condition | UI behaviour | | ------------------------------------ | ------------------------------------------------------------------- | | 200 from `send-email-code` | advance to step `code`, focus the code input | | `invalid_request` from `send` | stay on step `email`, surface the gateway message | | `service_unavailable` from `send` | stay on step `email`, surface "service is temporarily unavailable" | | 200 from `confirm-email-code` | persist `device_session_id`, redirect to `/lobby` | | `invalid_request` from `confirm` | bounce to step `email`, message: "code expired or already used" | | any other error from `confirm` | stay on step `code`, surface the gateway message | `permanent_block` and any other authoritative rejection from the backend collapse into the same `invalid_request` envelope from the UI's perspective; the gateway does not differentiate them externally. ## Resend and change-email - **send a new code** — re-issues `sendEmailCode` for the same address. The backend may throttle by reusing the most recent challenge id; the UI does not need to know about that. - **change email** — clears the in-progress challenge and returns to step `email`. No backend call. ## Persistence and returning users After `confirm-email-code` succeeds, `session.signIn` writes the `device_session_id` into the IDB cache (`namespace=session`, `key=device-session-id`). On the next page load, `SessionStore.init` reads it back and settles `status` to `authenticated`, so the layout effect routes the user straight to `/lobby`. The keypair lives next to the id in the same database (object store `keypair`, key `device`). Clearing site data wipes both; the next load generates a fresh keypair and the user must log in again. This is the documented re-login path — there is no paired "reissue device session" flow in Phase 7. ## Browser support The keystore relies on WebCrypto Ed25519, which currently lands in Chrome ≥ 137, Firefox ≥ 130, Safari ≥ 17.4 (see [`storage.md`](storage.md) for the rationale). On boot the layout runs a sanity probe (`crypto.subtle.generateKey` for `Ed25519`); if it rejects, the layout switches to a `browser not supported` page instead of rendering `/login`. Phase 7 deliberately does not ship a JavaScript Ed25519 fallback — see Phase 6's "modern-browser baseline, no JS Ed25519 fallback" decision. ## Revocation The lobby layout opens a long-running `SubscribeEvents` stream as soon as `status` becomes `authenticated`. The watcher does not process individual events in Phase 7 — that arrives in Phase 24. Its only contract is liveness: any non-aborted termination of the stream is treated as a server-side session revocation, the watcher calls `session.signOut("revoked")`, and the layout effect redirects to `/login`. This satisfies the Phase 7 acceptance bar of "session revocation closes the active client within one second": the gateway closes the stream the moment it observes a `session_invalidation` push event from backend, and the watcher reacts on the next event-loop tick. ## Localisation The login form, the root layout's blocker page, and the lobby placeholder go through the i18n primitive in `src/lib/i18n/`. The language picker on `/login` lists every entry in `SUPPORTED_LOCALES` by its native name and is initialised from `navigator.languages` (web) with `en` as the fallback. Picking a different language re-renders the form in place and is forwarded to the gateway in the JSON body of `send-email-code` (`locale` field) — the body channel is the canonical one because Safari drops JS-set `Accept-Language` headers. See [`i18n.md`](i18n.md) for the architecture and the recipe for adding a new language. The locale is **not** persisted between page reloads; detection runs again on every visit. Phase 35's full polish pass will revisit persistence and add message-format pluralisation. ## Configuration Build-time environment, read by `lib/env.ts`: | Variable | Format | Notes | | ------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------ | | `VITE_GATEWAY_BASE_URL` | URL string | gateway public REST surface and Connect-Web edge listener (same host); defaults to `http://localhost:8080` | | `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` | standard base64, 32 raw Ed25519 bytes | response-signing public key; only needed on authenticated routes | For local development against the integration suite, use the public key the gateway container exposes (`ResponseSignerPublic` in `integration/testenv/gateway.go`). Playwright derives both halves of the pair from `tests/e2e/fixtures/gateway-key.ts` and pins the public half through `playwright.config.ts`'s `webServer.env`. An empty `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` does not block app boot; the lobby surfaces an inline error when the first `executeCommand` would have otherwise been issued. ## Testing - **Vitest** — `tests/auth-api.test.ts`, `tests/session-store.test.ts`, `tests/login-page.test.ts` cover the wire shape, persistence state machine, and the form behaviours respectively. - **Playwright** — `tests/e2e/auth-flow.spec.ts` exercises the full happy path, returning-user resume, revocation within one second, and the browser-not-supported blocker. The gateway is mocked via `page.route(...)`; the lobby's `user.account.get` call is answered with a fixture-signed `ExecuteCommandResponse`. The Go-side integration suite (`integration/auth_flow_test.go`) covers the live wire contract; this UI doc deliberately stops at the boundaries above.