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:
@@ -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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPA mode: every route is rendered client-side, no SSR or
|
||||
// prerendering. The static adapter serves `fallback: "index.html"` and
|
||||
// the layout-level session bootstrap drives the rest of the app from
|
||||
// the browser only.
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Lobby is the first authenticated screen and depends on the
|
||||
// session keypair plus the WASM core loaded at runtime; SSR and
|
||||
// prerendering stay disabled.
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -0,0 +1,237 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
AuthError,
|
||||
confirmEmailCode,
|
||||
sendEmailCode,
|
||||
} from "../../api/auth";
|
||||
import { GATEWAY_BASE_URL } from "$lib/env";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
|
||||
type Step = "email" | "code";
|
||||
|
||||
let step: Step = $state("email");
|
||||
let email = $state("");
|
||||
let code = $state("");
|
||||
let challengeId: string | null = $state(null);
|
||||
let pending = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
function describe(err: unknown): string {
|
||||
if (err instanceof AuthError) {
|
||||
return err.message;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return "request failed";
|
||||
}
|
||||
|
||||
async function submitEmail(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const trimmed = email.trim();
|
||||
if (trimmed.length === 0) {
|
||||
error = "email must not be empty";
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed);
|
||||
challengeId = result.challengeId;
|
||||
code = "";
|
||||
step = "code";
|
||||
} catch (err) {
|
||||
error = describe(err);
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCode(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const trimmedCode = code.trim();
|
||||
if (trimmedCode.length === 0) {
|
||||
error = "code must not be empty";
|
||||
return;
|
||||
}
|
||||
if (challengeId === null) {
|
||||
error = "challenge expired, please request a new code";
|
||||
step = "email";
|
||||
return;
|
||||
}
|
||||
if (session.keypair === null) {
|
||||
error = "device key is not ready, please reload the page";
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await confirmEmailCode(GATEWAY_BASE_URL, {
|
||||
challengeId,
|
||||
code: trimmedCode,
|
||||
publicKey: session.keypair.publicKey,
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
await session.signIn(result.deviceSessionId);
|
||||
void goto("/lobby", { replaceState: true });
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError && err.code === "invalid_request") {
|
||||
challengeId = null;
|
||||
code = "";
|
||||
step = "email";
|
||||
error = "code expired or already used, please request a new one";
|
||||
} else {
|
||||
error = describe(err);
|
||||
}
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resend(): Promise<void> {
|
||||
if (pending) return;
|
||||
const trimmed = email.trim();
|
||||
if (trimmed.length === 0) {
|
||||
step = "email";
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed);
|
||||
challengeId = result.challengeId;
|
||||
code = "";
|
||||
} catch (err) {
|
||||
error = describe(err);
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changeEmail(): void {
|
||||
challengeId = null;
|
||||
code = "";
|
||||
error = null;
|
||||
step = "email";
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>sign in to Galaxy</h1>
|
||||
|
||||
{#if step === "email"}
|
||||
<form onsubmit={submitEmail} aria-busy={pending}>
|
||||
<label>
|
||||
email
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
bind:value={email}
|
||||
disabled={pending}
|
||||
required
|
||||
data-testid="login-email-input"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
data-testid="login-email-submit"
|
||||
>
|
||||
{pending ? "sending…" : "send code"}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form onsubmit={submitCode} aria-busy={pending}>
|
||||
<p data-testid="login-code-target">code sent to {email}</p>
|
||||
<label>
|
||||
code
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
bind:value={code}
|
||||
disabled={pending}
|
||||
required
|
||||
data-testid="login-code-input"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={pending} data-testid="login-code-submit">
|
||||
{pending ? "verifying…" : "verify"}
|
||||
</button>
|
||||
<div class="secondary">
|
||||
<button
|
||||
type="button"
|
||||
onclick={resend}
|
||||
disabled={pending}
|
||||
data-testid="login-resend"
|
||||
>
|
||||
send a new code
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={changeEmail}
|
||||
disabled={pending}
|
||||
data-testid="login-change-email"
|
||||
>
|
||||
change email
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if error !== null}
|
||||
<p role="alert" data-testid="login-error">{error}</p>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.secondary button {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
[role="alert"] {
|
||||
margin-top: 1rem;
|
||||
color: #b00020;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Login depends on browser-only WebCrypto and IndexedDB through the
|
||||
// session store; SSR and prerendering are disabled to keep the
|
||||
// component out of the server-render pipeline.
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
Reference in New Issue
Block a user