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
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import { onMount } from "svelte";
import { createEdgeGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
let displayName: string | null = $state(null);
let accountError: string | null = $state(null);
let accountLoading = $state(true);
async function logout(): Promise<void> {
await session.signOut("user");
}
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest(
"SHA-256",
payload as BufferSource,
);
return new Uint8Array(digest);
}
onMount(async () => {
if (
session.keypair === null ||
session.deviceSessionId === null ||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
) {
accountLoading = false;
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
accountError =
"VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
}
return;
}
const keypair = session.keypair;
try {
const core = await loadCore();
const client = new GalaxyClient({
core,
edge: createEdgeGatewayClient(GATEWAY_BASE_URL),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId: session.deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
const payload = await client.executeCommand(
"user.account.get",
new TextEncoder().encode("{}"),
);
const decoded = JSON.parse(new TextDecoder().decode(payload)) as {
account?: { display_name?: string; user_name?: string };
};
displayName =
decoded.account?.display_name ?? decoded.account?.user_name ?? null;
} catch (err) {
accountError = err instanceof Error ? err.message : "request failed";
} finally {
accountLoading = false;
}
});
</script>
<main>
<h1>you are logged in</h1>
<p>
device session id: <code data-testid="device-session-id"
>{session.deviceSessionId ?? ""}</code
>
</p>
{#if accountLoading}
<p>loading account…</p>
{:else if displayName !== null}
<p>
hello, <span data-testid="account-display-name">{displayName}</span>!
</p>
{:else if accountError !== null}
<p role="alert" data-testid="account-error">{accountError}</p>
{/if}
<button onclick={logout} data-testid="lobby-logout">logout</button>
</main>
<style>
main {
padding: 2rem;
font-family: system-ui, sans-serif;
}
button {
font-size: 1rem;
padding: 0.5rem 1rem;
margin-top: 1rem;
}
</style>