Files
galaxy-game/ui/frontend/src/lib/screens/login-screen.svelte
T
Ilia Denisov b6770d394c feat(ui): app-shell core — single-route dispatcher, route collapse, nav→state
Collapse the game UI to one route (`/`): a screen dispatcher renders
login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of
URL routes. Move screen components to lib/screens & lib/game; the game shell
reads the game id from `appScreen.gameId` and re-inits per-game stores via an
$effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites
to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now
state-based. WIP: browser-history (Back→lobby), restore-validation, the
return-to-lobby button, push deep-links, and the test migration are follow-ups
on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:04:04 +02:00

346 lines
7.2 KiB
Svelte

<script lang="ts">
import { appScreen } from "$lib/app-nav.svelte";
import {
AuthError,
confirmEmailCode,
sendEmailCode,
} from "../../api/auth";
import { GATEWAY_BASE_URL } from "$lib/env";
import { i18n, SUPPORTED_LOCALES } from "$lib/i18n/index.svelte";
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);
// Safari ignores `autocomplete="off"` on type=email / login-shaped
// fields and pops the Keychain suggester regardless. The classic
// workaround is to render the input as `readonly` initially —
// Safari does not autofill readonly fields — and drop the
// attribute on the first user focus so typing still works. Once
// dropped, the flag stays false for the rest of the page life.
let emailReadonly = $state(true);
let codeReadonly = $state(true);
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 = i18n.t("login.email_required");
return;
}
pending = true;
error = null;
try {
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
locale: i18n.locale,
});
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 = i18n.t("login.code_required");
return;
}
if (challengeId === null) {
error = i18n.t("login.challenge_expired");
step = "email";
return;
}
if (session.keypair === null) {
error = i18n.t("login.device_key_not_ready");
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);
appScreen.go("lobby");
} catch (err) {
if (err instanceof AuthError && err.code === "invalid_request") {
challengeId = null;
code = "";
step = "email";
error = i18n.t("login.code_expired_or_used");
} 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, {
locale: i18n.locale,
});
challengeId = result.challengeId;
code = "";
} catch (err) {
error = describe(err);
} finally {
pending = false;
}
}
function changeEmail(): void {
challengeId = null;
code = "";
error = null;
step = "email";
}
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
<main id="main-content" tabindex="-1">
<header>
<h1>{i18n.t("login.title")}</h1>
<div class="language-picker">
<svg
class="globe"
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
focusable="false"
>
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M3 12h18"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M12 3a13 13 0 0 1 0 18M12 3a13 13 0 0 0 0 18"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
<label class="sr-only" for="login-language-select">
{i18n.t("common.language")}
</label>
<select
id="login-language-select"
data-testid="login-language-select"
bind:value={i18n.locale}
>
{#each SUPPORTED_LOCALES as locale (locale.code)}
<option value={locale.code}>{locale.nativeName}</option>
{/each}
</select>
</div>
</header>
{#if step === "email"}
<form
onsubmit={submitEmail}
aria-busy={pending}
autocomplete="off"
>
<label>
{i18n.t("login.email_label")}
<input
type="email"
name="galaxy-login-email"
autocomplete="new-password"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
readonly={emailReadonly}
onfocus={() => (emailReadonly = false)}
bind:value={email}
disabled={pending}
required
data-testid="login-email-input"
/>
</label>
<button
type="submit"
disabled={pending}
data-testid="login-email-submit"
>
{pending ? i18n.t("login.sending") : i18n.t("login.send_code")}
</button>
</form>
{:else}
<form
onsubmit={submitCode}
aria-busy={pending}
autocomplete="off"
>
<p data-testid="login-code-target">
{i18n.t("login.code_sent_to", { email })}
</p>
<label>
{i18n.t("login.code_label")}
<input
type="text"
name="galaxy-login-code"
inputmode="numeric"
autocomplete="new-password"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
readonly={codeReadonly}
onfocus={() => (codeReadonly = false)}
bind:value={code}
disabled={pending}
required
data-testid="login-code-input"
/>
</label>
<button type="submit" disabled={pending} data-testid="login-code-submit">
{pending ? i18n.t("login.verifying") : i18n.t("login.verify")}
</button>
<div class="secondary">
<button
type="button"
onclick={resend}
disabled={pending}
data-testid="login-resend"
>
{i18n.t("login.send_new_code")}
</button>
<button
type="button"
onclick={changeEmail}
disabled={pending}
data-testid="login-change-email"
>
{i18n.t("login.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;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
header h1 {
margin: 0;
}
.language-picker {
display: inline-flex;
align-items: center;
gap: 0.5rem;
opacity: 0.85;
}
.globe {
flex: none;
}
.language-picker select {
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1.5rem;
}
form > 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: var(--color-danger);
}
</style>