Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the
login form, the layout blocker page, and the lobby placeholder.
SUPPORTED_LOCALES drives both the picker and the runtime lookup;
adding a language is a two-step change inside `src/lib/i18n/`.
Login form gains a globe-icon language dropdown (English / Русский
in their native names), defaulting to navigator.languages with `en`
as the fallback. Switching the locale re-renders the form in place;
on submit, the locale rides in the JSON body of `send-email-code`
because Safari/WebKit silently drops JS-set Accept-Language. Gateway
gains a body `locale` field that takes priority over the request
header for preferred-language resolution.
Email and code inputs disable browser autofill / suggestions
(`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` +
`spellcheck=false`) so Keychain / address-book pickers and
remembered-value dropdowns no longer fire on focus.
Cross-cuts:
- backend & gateway openapi: clarify that body `locale` is honored.
- docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority.
- gateway tests: body `locale` overrides Accept-Language; blank
body `locale` falls back to header.
- new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8.3 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.ts—sendEmailCode,confirmEmailCode, and theAuthErrortaxonomy.ui/frontend/src/lib/session-store.svelte.ts— singleton reactive state (status,keypair,deviceSessionId) plusinit,signIn,signOut.ui/frontend/src/lib/revocation-watcher.ts— minimalSubscribeEventswatcher that triggerssignOut("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 authenticateduser.account.get.ui/frontend/src/routes/+layout.svelte— route guard plus 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
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). 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
sendEmailCodefor 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 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 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.tscover the wire shape, persistence state machine, and the form behaviours respectively. - Playwright —
tests/e2e/auth-flow.spec.tsexercises the full happy path, returning-user resume, revocation within one second, and the browser-not-supported blocker. The gateway is mocked viapage.route(...); the lobby'suser.account.getcall is answered with a fixture-signedExecuteCommandResponse.
The Go-side integration suite (integration/auth_flow_test.go)
covers the live wire contract; this UI doc deliberately stops at
the boundaries above.