Files
galaxy-game/ui/docs/auth-flow.md
T
Ilia Denisov 3d5b331bd9
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m51s
feat(ui): autofocus login fields; keep verification code out of form history
The two-step e-mail login now drops the cursor on each step's primary
field as it mounts — the e-mail field on load, the code field once the
e-mail step advances — via a small `use:` action. Focusing fires each
input's onfocus, which clears the readonly autofill guard, so the field
is editable straight away.

The code input now requests `autocomplete="one-time-code"` instead of
`new-password`. The latter is a password-manager hint and does not stop
Firefox saving the typed code to form history (it was offering the
previous code back in a dropdown). `one-time-code` is the semantic token
for a verification code; Firefox honours it specifically to keep the
value out of form history (Mozilla bug 1547294). The e-mail field keeps
`new-password` to fend off saved-login autofill.

Tests: new Vitest cases assert autofocus on both steps and the code
field's `one-time-code` token; a new Playwright case covers the same in
Chromium and WebKit (Safari engine). Firefox form history is owner
manual-QA — there is no Firefox project in the e2e matrix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:53:20 +02:00

10 KiB

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.

Surface

  • ui/frontend/src/api/auth.tssendEmailCode, 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/lib/screens/login-screen.svelte — two-step form.
  • ui/frontend/src/lib/screens/lobby-screen.svelte — lobby that issues the first authenticated user.account.get.
  • ui/frontend/src/routes/+page.svelte — the state-based auth gate / screen dispatcher (anonymous → login, authenticated → the appScreen screen). The single-URL app-shell has no per-screen routes; see navigation.md.
  • ui/frontend/src/routes/+layout.svelte — boot-time session init, the loading / unsupported interception, and the browser-not-supported blocker.

State machine (SessionStatus)

                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 settle status to anonymous, which the dispatcher renders as the login screen — there is no URL redirect (the app-shell stays at /game/).

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). 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, settle status to authenticated (dispatcher shows the 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.

Focus and autofill suppression

The login screen drops the cursor on each step's primary field the moment it mounts — the e-mail field on load, the code field once the e-mail step advances — so the user can type without first clicking. This is wired with a one-line use: action that focuses the node on the next tick.

Both inputs render readonly initially and drop the attribute on first focus (user-driven or via the autofocus action). Safari ignores autocomplete="off" on login-shaped fields and pops the Keychain suggester on load, but it never autofills a readonly field, so the page loads quiet and each field turns editable as soon as it is focused.

Autofill intent then differs per field:

  • the e-mail field asks for autocomplete="new-password" to keep password managers from injecting a saved login;
  • the code field asks for autocomplete="one-time-code", the semantic token for a verification code. It is the reliable way to keep Firefox from saving the code to form history and offering it back in a dropdown — Firefox honours that token specifically, while plain autocomplete="off" is not respected for this field (Mozilla bug 1547294).

Playwright covers the autofocus and the code field's token in WebKit (Safari engine) and Chromium; Firefox form-history behaviour is verified by hand, as there is no Firefox project in the e2e matrix.

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 dispatcher renders the authenticated screen straight away. Which authenticated screen it is comes from the restored appScreen snapshot (lobby by default; see navigation.md), not from the URL.

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.

Browser support

The keystore relies on WebCrypto Ed25519, which currently lands in Chrome ≥ 137, Firefox ≥ 130, Safari ≥ 17.4 (see storage.md for the rationale). On boot the root layout runs a sanity probe (crypto.subtle.generateKey for Ed25519); if it rejects, status settles to unsupported and the layout renders a browser not supported page instead of the login screen. The client deliberately does not ship a JavaScript Ed25519 fallback — the design decision is modern-browser baseline only.

Revocation

The root layout opens a long-running SubscribeEvents stream as soon as status becomes authenticated. 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"), status settles to anonymous, and the dispatcher swaps to the login screen on the next render — the URL stays /game/.

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 screen go through the i18n primitive in src/lib/i18n/. The language picker on the login screen 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 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. Persistence and message-format pluralisation are deferred to the finalization plan (../Plan-finalize.md).

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

  • Vitesttests/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.
  • Playwrighttests/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.