phase 7: auth flow UI (email-code login + session resume + revocation)

Implements ui/PLAN.md Phase 7 end-to-end:

- /login two-step form (email -> code) over the gateway public REST
  surface; /lobby placeholder issues the first authenticated
  user.account.get and renders the decoded display name.
- SessionStore (Svelte 5 runes) with loading / unsupported / anonymous /
  authenticated states; layout-level route guard, browser-not-supported
  blocker, and a minimal SubscribeEvents revocation watcher that closes
  the active client within 1s on a clean stream end or
  Unauthenticated.
- VITE_GATEWAY_BASE_URL + VITE_GATEWAY_RESPONSE_PUBLIC_KEY config plus
  AuthError taxonomy in api/auth.ts.
- Vitest (auth-api, session-store, login-page) and Playwright e2e
  (auth-flow.spec.ts) on the four configured projects, with a fixture
  Ed25519 keypair forging Connect-Web JSON responses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-07 15:24:21 +02:00
parent 390ad3196b
commit 22b0710d04
24 changed files with 2125 additions and 48 deletions
+66 -1
View File
@@ -1,5 +1,70 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { session } from "$lib/session-store.svelte";
import { startRevocationWatcher } from "$lib/revocation-watcher";
let { children } = $props();
let stopWatcher: (() => void) | null = null;
onMount(() => {
void session.init();
return () => {
if (stopWatcher !== null) {
stopWatcher();
stopWatcher = null;
}
};
});
$effect(() => {
if (session.status === "authenticated" && stopWatcher === null) {
stopWatcher = startRevocationWatcher();
} else if (session.status !== "authenticated" && stopWatcher !== null) {
stopWatcher();
stopWatcher = null;
}
const pathname = page.url.pathname;
// Debug-only routes under /__debug/* run their own bootstrap
// path against the storage primitives and must bypass the
// auth guard so Phase 6's Playwright spec can drive the
// keystore directly.
if (pathname.startsWith("/__debug/")) {
return;
}
if (session.status === "anonymous" && pathname !== "/login") {
void goto("/login", { replaceState: true });
} else if (session.status === "authenticated" && pathname === "/login") {
void goto("/lobby", { replaceState: true });
}
});
</script>
{@render children()}
{#if session.status === "loading"}
<main class="status">
<p>loading…</p>
</main>
{:else if session.status === "unsupported"}
<main class="status">
<h1>browser not supported</h1>
<p>
Galaxy requires Ed25519 in WebCrypto. The minimum supported browser
versions are listed in the
<a href="https://github.com/galaxy/galaxy/blob/main/ui/docs/storage.md"
>storage topic doc</a
>.
</p>
</main>
{:else}
{@render children()}
{/if}
<style>
.status {
padding: 2rem;
font-family: system-ui, sans-serif;
}
</style>