phase 7+: i18n primitive + login language picker + autocomplete-off
Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the
login form, the layout blocker page, and the lobby placeholder.
SUPPORTED_LOCALES drives both the picker and the runtime lookup;
adding a language is a two-step change inside `src/lib/i18n/`.
Login form gains a globe-icon language dropdown (English / Русский
in their native names), defaulting to navigator.languages with `en`
as the fallback. Switching the locale re-renders the form in place;
on submit, the locale rides in the JSON body of `send-email-code`
because Safari/WebKit silently drops JS-set Accept-Language. Gateway
gains a body `locale` field that takes priority over the request
header for preferred-language resolution.
Email and code inputs disable browser autofill / suggestions
(`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` +
`spellcheck=false`) so Keychain / address-book pickers and
remembered-value dropdowns no longer fire on focus.
Cross-cuts:
- backend & gateway openapi: clarify that body `locale` is honored.
- docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority.
- gateway tests: body `locale` overrides Accept-Language; blank
body `locale` falls back to header.
- new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { startRevocationWatcher } from "$lib/revocation-watcher";
|
||||
|
||||
@@ -45,18 +46,12 @@
|
||||
|
||||
{#if session.status === "loading"}
|
||||
<main class="status">
|
||||
<p>loading…</p>
|
||||
<p>{i18n.t("common.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>
|
||||
<h1>{i18n.t("common.browser_not_supported_title")}</h1>
|
||||
<p>{i18n.t("common.browser_not_supported_body")}</p>
|
||||
</main>
|
||||
{:else}
|
||||
{@render children()}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createEdgeGatewayClient } from "../../api/connect";
|
||||
import { GalaxyClient } from "../../api/galaxy-client";
|
||||
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { loadCore } from "../../platform/core/index";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
|
||||
@@ -64,22 +65,23 @@
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>you are logged in</h1>
|
||||
<h1>{i18n.t("lobby.title")}</h1>
|
||||
<p>
|
||||
device session id: <code data-testid="device-session-id"
|
||||
>{session.deviceSessionId ?? ""}</code
|
||||
>
|
||||
{i18n.t("lobby.device_session_id_label")}:
|
||||
<code data-testid="device-session-id">{session.deviceSessionId ?? ""}</code>
|
||||
</p>
|
||||
{#if accountLoading}
|
||||
<p>loading account…</p>
|
||||
<p>{i18n.t("lobby.account_loading")}</p>
|
||||
{:else if displayName !== null}
|
||||
<p>
|
||||
hello, <span data-testid="account-display-name">{displayName}</span>!
|
||||
<p data-testid="account-greeting">
|
||||
{i18n.t("lobby.greeting", { name: displayName })}
|
||||
</p>
|
||||
{:else if accountError !== null}
|
||||
<p role="alert" data-testid="account-error">{accountError}</p>
|
||||
{/if}
|
||||
<button onclick={logout} data-testid="lobby-logout">logout</button>
|
||||
<button onclick={logout} data-testid="lobby-logout">
|
||||
{i18n.t("lobby.logout")}
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
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";
|
||||
@@ -32,13 +33,15 @@
|
||||
if (pending) return;
|
||||
const trimmed = email.trim();
|
||||
if (trimmed.length === 0) {
|
||||
error = "email must not be empty";
|
||||
error = i18n.t("login.email_required");
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed);
|
||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
|
||||
locale: i18n.locale,
|
||||
});
|
||||
challengeId = result.challengeId;
|
||||
code = "";
|
||||
step = "code";
|
||||
@@ -54,16 +57,16 @@
|
||||
if (pending) return;
|
||||
const trimmedCode = code.trim();
|
||||
if (trimmedCode.length === 0) {
|
||||
error = "code must not be empty";
|
||||
error = i18n.t("login.code_required");
|
||||
return;
|
||||
}
|
||||
if (challengeId === null) {
|
||||
error = "challenge expired, please request a new code";
|
||||
error = i18n.t("login.challenge_expired");
|
||||
step = "email";
|
||||
return;
|
||||
}
|
||||
if (session.keypair === null) {
|
||||
error = "device key is not ready, please reload the page";
|
||||
error = i18n.t("login.device_key_not_ready");
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
@@ -82,7 +85,7 @@
|
||||
challengeId = null;
|
||||
code = "";
|
||||
step = "email";
|
||||
error = "code expired or already used, please request a new one";
|
||||
error = i18n.t("login.code_expired_or_used");
|
||||
} else {
|
||||
error = describe(err);
|
||||
}
|
||||
@@ -101,7 +104,9 @@
|
||||
pending = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed);
|
||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
|
||||
locale: i18n.locale,
|
||||
});
|
||||
challengeId = result.challengeId;
|
||||
code = "";
|
||||
} catch (err) {
|
||||
@@ -120,16 +125,68 @@
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>sign in to Galaxy</h1>
|
||||
<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="visually-hidden" 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}>
|
||||
<form
|
||||
onsubmit={submitEmail}
|
||||
aria-busy={pending}
|
||||
autocomplete="off"
|
||||
>
|
||||
<label>
|
||||
email
|
||||
{i18n.t("login.email_label")}
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
name="galaxy-login-email"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={email}
|
||||
disabled={pending}
|
||||
required
|
||||
@@ -141,19 +198,28 @@
|
||||
disabled={pending}
|
||||
data-testid="login-email-submit"
|
||||
>
|
||||
{pending ? "sending…" : "send code"}
|
||||
{pending ? i18n.t("login.sending") : i18n.t("login.send_code")}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form onsubmit={submitCode} aria-busy={pending}>
|
||||
<p data-testid="login-code-target">code sent to {email}</p>
|
||||
<form
|
||||
onsubmit={submitCode}
|
||||
aria-busy={pending}
|
||||
autocomplete="off"
|
||||
>
|
||||
<p data-testid="login-code-target">
|
||||
{i18n.t("login.code_sent_to", { email })}
|
||||
</p>
|
||||
<label>
|
||||
code
|
||||
{i18n.t("login.code_label")}
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
name="galaxy-login-code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={code}
|
||||
disabled={pending}
|
||||
required
|
||||
@@ -161,7 +227,7 @@
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={pending} data-testid="login-code-submit">
|
||||
{pending ? "verifying…" : "verify"}
|
||||
{pending ? i18n.t("login.verifying") : i18n.t("login.verify")}
|
||||
</button>
|
||||
<div class="secondary">
|
||||
<button
|
||||
@@ -170,7 +236,7 @@
|
||||
disabled={pending}
|
||||
data-testid="login-resend"
|
||||
>
|
||||
send a new code
|
||||
{i18n.t("login.send_new_code")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -178,7 +244,7 @@
|
||||
disabled={pending}
|
||||
data-testid="login-change-email"
|
||||
>
|
||||
change email
|
||||
{i18n.t("login.change_email")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -196,6 +262,46 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -203,7 +309,7 @@
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
form > label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
Reference in New Issue
Block a user