# 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/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`](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`)

```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 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](../../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`, 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](https://bugzilla.mozilla.org/show_bug.cgi?id=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`](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`](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`](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

- **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.
