diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 47e82c9..5226e40 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -2303,7 +2303,13 @@ components: format: email locale: type: string - description: Optional BCP 47 locale tag preferred for the delivered code. + description: | + Optional BCP 47 locale tag preferred for the delivered code. + Read by the gateway in preference to the request + `Accept-Language` header so Safari clients (which silently + drop JS-set `Accept-Language`) can still pick a non-system + mail language. Empty / malformed values fall back to the + header, which in turn falls back to `en`. PublicAuthSendEmailCodeResponse: type: object additionalProperties: false diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index caa336d..751d861 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -100,12 +100,15 @@ Branches inside backend: new one. The client gets the same response shape and is unaware of the reuse. - **Otherwise.** Backend creates a new challenge with the resolved - preferred language (derived from the optional `Accept-Language` - header forwarded by gateway, falling back to a default), and - enqueues the auth-mail row directly into the outbox in the same - transaction. SMTP delivery is asynchronous; the auth response - returns as soon as the challenge and outbox rows are durably - committed. + preferred language (derived from the optional `locale` body field + the caller sends — which takes priority — or, if absent or blank, + from the `Accept-Language` header forwarded by gateway, falling + back to a default), and enqueues the auth-mail row directly into + the outbox in the same transaction. SMTP delivery is asynchronous; + the auth response returns as soon as the challenge and outbox rows + are durably committed. The body field is the canonical channel + because Safari silently drops JS-set `Accept-Language` headers; + non-Safari clients can still rely on the header alone. ### 1.3 Confirming the challenge diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 96bc532..7d74c4e 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -99,11 +99,15 @@ Backend выпускает непрозрачный идентификатор backend переиспользует последний имеющийся вызов вместо создания нового. Клиент получает ту же форму ответа и не знает о повторе. - **Иначе.** Backend создаёт новый вызов с разрешённым preferred_language - (выводится из опционального заголовка `Accept-Language`, - форварднутого gateway, с откатом на дефолт) и в той же транзакции - ставит auth-mail-строку прямо в outbox. SMTP-доставка асинхронна; - auth-ответ возвращается, как только строки challenge и outbox - durably закоммитены. + (выводится из опционального поля `locale` в JSON-теле — оно имеет + приоритет — либо, если оно отсутствует или пустое, из заголовка + `Accept-Language`, форварднутого gateway, с откатом на дефолт) и + в той же транзакции ставит auth-mail-строку прямо в outbox. + SMTP-доставка асинхронна; auth-ответ возвращается, как только + строки challenge и outbox durably закоммитены. Поле в теле — это + канонический канал, потому что Safari молча сбрасывает выставляемые + из JS заголовки `Accept-Language`; клиентам не на Safari достаточно + одного заголовка. ### 1.3 Подтверждение вызова diff --git a/gateway/internal/restapi/public_auth.go b/gateway/internal/restapi/public_auth.go index 6e58e34..bc0c280 100644 --- a/gateway/internal/restapi/public_auth.go +++ b/gateway/internal/restapi/public_auth.go @@ -56,9 +56,16 @@ type SendEmailCodeInput struct { // code challenge. Email string `json:"email"` - // PreferredLanguage stores the canonical BCP 47 language tag derived from - // the public Accept-Language header for upstream auth-mail localization and - // create-only user registration context. + // Locale is the optional BCP 47 language tag the caller wants the + // auth-mail in. The body field is the canonical channel because Safari + // silently drops JS-set Accept-Language headers; when set, it overrides + // the request Accept-Language for preferred-language resolution. + Locale string `json:"locale,omitempty"` + + // PreferredLanguage stores the canonical BCP 47 language tag derived + // from Locale (preferred) or the Accept-Language header (fallback) for + // upstream auth-mail localization and create-only user registration + // context. PreferredLanguage string `json:"-"` } @@ -209,7 +216,15 @@ func handleSendEmailCode(authService AuthServiceClient, timeout time.Duration) g abortInvalidRequest(c, err.Error()) return } - input.PreferredLanguage = resolvePreferredLanguage(c.Request.Header.Get("Accept-Language")) + // Body locale wins over the request header so Safari clients, + // which cannot set Accept-Language from JavaScript, can still + // pick a non-system mail language. Empty / malformed values + // fall through resolvePreferredLanguage to the default. + if strings.TrimSpace(input.Locale) != "" { + input.PreferredLanguage = resolvePreferredLanguage(input.Locale) + } else { + input.PreferredLanguage = resolvePreferredLanguage(c.Request.Header.Get("Accept-Language")) + } callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout) defer cancel() diff --git a/gateway/internal/restapi/public_auth_test.go b/gateway/internal/restapi/public_auth_test.go index b801394..05cdf26 100644 --- a/gateway/internal/restapi/public_auth_test.go +++ b/gateway/internal/restapi/public_auth_test.go @@ -52,6 +52,64 @@ func TestSendEmailCodeHandlerSuccess(t *testing.T) { assert.Equal(t, PublicRouteClassPublicAuth, authService.sendEmailCodeRouteClass) } +func TestSendEmailCodeHandlerBodyLocaleOverridesHeader(t *testing.T) { + t.Parallel() + + authService := &recordingAuthServiceClient{ + sendEmailCodeResult: SendEmailCodeResult{ + ChallengeID: "challenge-456", + }, + } + handler := newPublicHandler(ServerDependencies{AuthService: authService}) + + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/public/auth/send-email-code", + strings.NewReader(`{"email":"pilot@example.com","locale":"ru"}`), + ) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept-Language", "fr-FR") + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, SendEmailCodeInput{ + Email: "pilot@example.com", + Locale: "ru", + PreferredLanguage: "ru", + }, authService.sendEmailCodeInput) +} + +func TestSendEmailCodeHandlerEmptyBodyLocaleFallsBackToHeader(t *testing.T) { + t.Parallel() + + authService := &recordingAuthServiceClient{ + sendEmailCodeResult: SendEmailCodeResult{ + ChallengeID: "challenge-789", + }, + } + handler := newPublicHandler(ServerDependencies{AuthService: authService}) + + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/public/auth/send-email-code", + strings.NewReader(`{"email":"pilot@example.com","locale":" "}`), + ) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept-Language", "ru-RU") + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, SendEmailCodeInput{ + Email: "pilot@example.com", + Locale: " ", + PreferredLanguage: "ru-RU", + }, authService.sendEmailCodeInput) +} + func TestConfirmEmailCodeHandlerSuccess(t *testing.T) { t.Parallel() diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index 7ee86c5..d829135 100644 --- a/gateway/openapi.yaml +++ b/gateway/openapi.yaml @@ -134,10 +134,12 @@ paths: that must later be confirmed through `POST /api/v1/public/auth/confirm-email-code`. - The JSON body stays unchanged. Callers may additionally supply the - standard `Accept-Language` header so the gateway can derive the - auth-mail locale and first-login preferred-language candidate. Missing - or unsupported values fall back to `en`. + Callers select the auth-mail locale through the optional + `locale` field on the JSON body, which takes priority over the + request `Accept-Language` header. The body field is the canonical + channel because Safari silently drops JS-set `Accept-Language` + headers; non-Safari clients can still rely on the header alone. + Missing or unsupported values fall back to `en`. This route is unauthenticated and classified as `public_auth`. Public REST anti-abuse applies a per-IP bucket derived from @@ -302,6 +304,16 @@ components: type: string description: Single client e-mail address that should receive the login code. format: email + locale: + type: string + description: | + Optional BCP 47 language tag the caller prefers for the + delivered code. The body field is the canonical channel + because Safari silently drops JS-set Accept-Language + headers; when set, it overrides the request + `Accept-Language` for preferred-language resolution. + Empty / malformed values fall back to the header, which + in turn falls back to `en`. SendEmailCodeResponse: type: object additionalProperties: false diff --git a/ui/README.md b/ui/README.md index e14345e..0566320 100644 --- a/ui/README.md +++ b/ui/README.md @@ -64,6 +64,7 @@ ui/ ├── buf.gen.yaml local-plugin TS Protobuf-ES generator ├── docs/ topic-based design notes │ ├── auth-flow.md email-code login, session store, revocation +│ ├── i18n.md translation primitive, native-name picker, extensibility │ ├── storage.md web KeyStore/Cache, IDB schema, baseline │ ├── testing.md per-PR / release test tiers │ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget @@ -83,6 +84,8 @@ Linked topic docs: - [`docs/auth-flow.md`](docs/auth-flow.md) — email-code login, session store state machine, revocation watcher. +- [`docs/i18n.md`](docs/i18n.md) — translation primitive, native-name + language picker, recipe for adding a new locale. - [`docs/storage.md`](docs/storage.md) — web KeyStore/Cache, IndexedDB schema, browser baseline. - [`docs/wasm-toolchain.md`](docs/wasm-toolchain.md) — TinyGo build, diff --git a/ui/docs/auth-flow.md b/ui/docs/auth-flow.md index 8358fc0..15e75a6 100644 --- a/ui/docs/auth-flow.md +++ b/ui/docs/auth-flow.md @@ -125,6 +125,24 @@ the stream the moment it observes a `session_invalidation` push event from backend, and the watcher reacts on the next event-loop tick. +## Localisation + +The login form, the root layout's blocker page, and the lobby +placeholder go through the i18n primitive in `src/lib/i18n/`. The +language picker on `/login` lists every entry in +`SUPPORTED_LOCALES` by its native name and is initialised from +`navigator.languages` (web) with `en` as the fallback. Picking a +different language re-renders the form in place and is forwarded +to the gateway in the JSON body of `send-email-code` (`locale` +field) — the body channel is the canonical one because Safari +drops JS-set `Accept-Language` headers. See +[`i18n.md`](i18n.md) for the architecture and the recipe for +adding a new language. + +The locale is **not** persisted between page reloads; detection +runs again on every visit. Phase 35's full polish pass will +revisit persistence and add message-format pluralisation. + ## Configuration Build-time environment, read by `lib/env.ts`: diff --git a/ui/docs/i18n.md b/ui/docs/i18n.md new file mode 100644 index 0000000..4dd92b4 --- /dev/null +++ b/ui/docs/i18n.md @@ -0,0 +1,143 @@ +# i18n (UI) + +The UI client ships with a minimal locale primitive used by the +phase-7 login form, the root layout, and the lobby placeholder. The +goal is just enough infrastructure to translate user-visible +strings, switch the active language at runtime, and forward the +caller's choice to the gateway. Phase 35 will swap this primitive +for a fuller solution once message-format pluralisation, lazy +loading, and translator workflows become necessary; until then, +the surface here covers every authenticated and unauthenticated +screen the client renders. + +## Surface + +``` +src/lib/i18n/ +├── index.svelte.ts # I18nStore singleton, types, SUPPORTED_LOCALES +└── locales/ + ├── en.ts # English dictionary (default, source of truth) + └── ru.ts # Russian dictionary (mirrors en.ts keys) +``` + +The exported singleton (`i18n`) is a Svelte 5 runes class with one +reactive field, `locale`, and a `t(key, params?)` lookup. Components +read translations through `i18n.t('login.title')` and re-render +automatically when `i18n.locale` changes. + +The runes singleton is a `.svelte.ts` file because Svelte 5 only +processes `$state` runes inside `.svelte`, `.svelte.js`, and +`.svelte.ts` modules. + +## Adding a language + +Two-step change inside `src/lib/i18n/`: + +1. Drop `locales/.ts` mirroring the shape of + `locales/en.ts`. The TypeScript signature on each non-English + file is `Record`, so the compiler + refuses to build until every key in `en.ts` is translated. +2. Register the new file in the `SUPPORTED_LOCALES` array in + `index.svelte.ts`. That single list drives the language picker + (UI) and the runtime lookup table. + +For example, adding French: + +```ts +// src/lib/i18n/locales/fr.ts +import type en from "./en"; +const fr: Record = { + "common.language": "langue", + /* …translate every other key… */ +}; +export default fr; +``` + +```ts +// src/lib/i18n/index.svelte.ts +import frTranslations from "./locales/fr"; + +export type Locale = "en" | "ru" | "fr"; + +export const SUPPORTED_LOCALES: readonly LocaleEntry[] = [ + { code: "en", nativeName: "English", translations: enTranslations }, + { code: "ru", nativeName: "Русский", translations: ruTranslations }, + { code: "fr", nativeName: "Français", translations: frTranslations }, +]; +``` + +No other code change is required: the picker, the detection helper, +the `t()` function and the gateway forwarding all derive from +`SUPPORTED_LOCALES`. + +## Detection + +`detectInitialLocale(preferences?)` returns the first +`SUPPORTED_LOCALES` entry whose `code` matches the primary subtag of +any preference, or `DEFAULT_LOCALE` (English) when nothing matches. + +The web target calls it without arguments, in which case the helper +reads `navigator.languages` (or `navigator.language` as fallback). +Native wrappers (Wails, Capacitor) will pass their system locale +once Phase 31/32 lands; the helper is platform-agnostic by design. + +The detection runs once at module load — there is no asynchronous +init step. Callers that mutate the locale (e.g. the language picker +on `/login`) call `i18n.setLocale(next)` directly. The choice is +**not** persisted between page reloads in Phase 7; the next visit +re-runs detection. Persistence is a phase-35 concern. + +## Forwarding the locale to the gateway + +The login form passes the active `i18n.locale` to +`sendEmailCode(baseUrl, email, { locale })`. The auth API places +the value inside the JSON body (`locale` field) rather than the +`Accept-Language` header: + +```ts +await sendEmailCode(GATEWAY_BASE_URL, trimmed, { locale: i18n.locale }); +``` + +The body field is the canonical channel because Safari/WebKit +silently drops JS-set `Accept-Language` headers (a long-standing +WebKit fingerprinting mitigation). The gateway reads the body +field with priority over the request `Accept-Language`, and +non-Safari clients can still rely on the header alone — the gateway +treats body and header as a single fallback chain. See +`gateway/internal/restapi/public_auth.go` for the resolution path +and `docs/FUNCTIONAL.md` §1.2 for the contract. + +The `confirm-email-code` endpoint does **not** carry the locale. +Per `docs/FUNCTIONAL.md` §1.3, the preferred language is captured +at challenge issuance and replayed from the challenge row. + +## Key conventions + +- Dotted keys grouped by feature area: `login.*`, `lobby.*`, + `common.*`. New screens own their own prefix. +- Templates may carry simple `{name}` placeholders. The `t()` + helper substitutes them with the caller-provided value via plain + string replacement; values are written to the DOM unescaped, so + callers must feed user-safe strings. +- Lookup falls back to the default locale and finally to the literal + key when a key is missing in the active locale. The TypeScript + signature on each locale file enforces complete coverage at build + time, so runtime fallback is the safety net for a freshly added + language that has not finished its translation pass. +- The translation file is the single source of truth — components + never hardcode user-visible English text; everything goes through + `i18n.t(...)`. + +## Testing + +- `tests/i18n.test.ts` covers `detectInitialLocale`, + `i18n.setLocale`, parameter interpolation, and the unknown-key + fallback. +- `tests/login-page.test.ts` asserts the language picker renders + with native names, switching the locale re-renders the form + text, and `sendEmailCode` receives the active locale. +- `tests/auth-api.test.ts` asserts the locale is forwarded through + the JSON body of `send-email-code`. +- `tests/e2e/auth-flow.spec.ts` covers the dropdown-driven switch + end-to-end on every Playwright project, including Safari (where + Accept-Language is unsettable from JS). diff --git a/ui/frontend/src/api/auth.ts b/ui/frontend/src/api/auth.ts index 94354eb..0f53965 100644 --- a/ui/frontend/src/api/auth.ts +++ b/ui/frontend/src/api/auth.ts @@ -22,6 +22,20 @@ export interface SendEmailCodeResult { challengeId: string; } +export interface SendEmailCodeOptions { + /** + * locale is forwarded inside the JSON body and read by the + * gateway in preference to the request `Accept-Language` header. + * The body field is the canonical channel because Safari/WebKit + * silently drops JS-set `Accept-Language` headers, while the + * body round-trips correctly on every supported engine. When the + * caller omits this option the browser-default Accept-Language + * remains the gateway's only signal and the auth-mail uses the + * system locale. + */ + locale?: string; +} + export interface ConfirmEmailCodeInput { challengeId: string; code: string; @@ -61,24 +75,32 @@ export class AuthError extends Error { export async function sendEmailCode( baseUrl: string, email: string, + options?: SendEmailCodeOptions, ): Promise { + const requestBody: Record = { email }; + if (options?.locale !== undefined && options.locale !== "") { + requestBody.locale = options.locale; + } const response = await fetch(joinUrl(baseUrl, SEND_EMAIL_CODE_PATH), { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ email }), + body: JSON.stringify(requestBody), }); if (!response.ok) { throw await readAuthError(response); } - const body = (await response.json()) as { challenge_id?: unknown }; - if (typeof body.challenge_id !== "string" || body.challenge_id.length === 0) { + const responseBody = (await response.json()) as { challenge_id?: unknown }; + if ( + typeof responseBody.challenge_id !== "string" || + responseBody.challenge_id.length === 0 + ) { throw new AuthError( "internal_error", "gateway returned a malformed send-email-code response", response.status, ); } - return { challengeId: body.challenge_id }; + return { challengeId: responseBody.challenge_id }; } /** diff --git a/ui/frontend/src/lib/i18n/index.svelte.ts b/ui/frontend/src/lib/i18n/index.svelte.ts new file mode 100644 index 0000000..d530d1f --- /dev/null +++ b/ui/frontend/src/lib/i18n/index.svelte.ts @@ -0,0 +1,150 @@ +// Lightweight i18n primitive used by the login form, the root +// layout, and the lobby placeholder. The translation table is a +// per-locale flat dictionary keyed by dotted strings; lookup falls +// back to the default (English) locale when a key is missing. +// +// Adding a new language is a two-step change inside this folder: +// 1. drop a `locales/.ts` file mirroring the shape of +// `locales/en.ts` (TypeScript enforces matching keys via the +// `Record` annotation in `ru.ts`); +// 2. register the file in `SUPPORTED_LOCALES` below — that single +// list drives the language picker UI and the runtime lookup +// table at the same time. +// +// The locale state is exposed through a Svelte 5 runes singleton +// (`i18n`) so components stay reactive without ceremony: +// `

{i18n.t('login.title')}

` re-renders whenever +// `i18n.locale` changes. Phase 35 will swap this primitive for a +// fuller solution once message-format pluralisation and lazy +// loading become necessary. + +import enTranslations from "./locales/en"; +import ruTranslations from "./locales/ru"; + +export type Locale = "en" | "ru"; +export type TranslationKey = keyof typeof enTranslations; + +export interface LocaleEntry { + readonly code: Locale; + readonly nativeName: string; + readonly translations: Readonly>; +} + +export const SUPPORTED_LOCALES: readonly LocaleEntry[] = [ + { + code: "en", + nativeName: "English", + translations: enTranslations, + }, + { + code: "ru", + nativeName: "Русский", + translations: ruTranslations, + }, +]; + +export const DEFAULT_LOCALE: Locale = "en"; + +const TRANSLATIONS_BY_LOCALE: Record< + Locale, + Readonly> +> = SUPPORTED_LOCALES.reduce( + (acc, entry) => { + acc[entry.code] = entry.translations; + return acc; + }, + {} as Record>>, +); + +/** + * detectInitialLocale returns the best supported locale match for + * the supplied BCP 47 preference list. The web target passes + * `navigator.languages`; native wrappers pass the system locale + * (one entry). The first preference whose primary subtag matches + * a `SUPPORTED_LOCALES` entry wins; otherwise [DEFAULT_LOCALE]. + */ +export function detectInitialLocale( + preferences?: readonly string[], +): Locale { + const prefs = preferences ?? readBrowserPreferences(); + for (const tag of prefs) { + const primary = primarySubtag(tag); + if (primary === null) { + continue; + } + const found = SUPPORTED_LOCALES.find((entry) => entry.code === primary); + if (found !== undefined) { + return found.code; + } + } + return DEFAULT_LOCALE; +} + +function readBrowserPreferences(): readonly string[] { + if (typeof navigator === "undefined") { + return []; + } + if (Array.isArray(navigator.languages) && navigator.languages.length > 0) { + return navigator.languages; + } + if (typeof navigator.language === "string" && navigator.language !== "") { + return [navigator.language]; + } + return []; +} + +function primarySubtag(tag: string): Locale | null { + const trimmed = tag.trim().toLowerCase(); + if (trimmed.length === 0) { + return null; + } + const code = trimmed.split(/[-_]/)[0] ?? ""; + return isLocale(code) ? code : null; +} + +function isLocale(value: string): value is Locale { + return SUPPORTED_LOCALES.some((entry) => entry.code === value); +} + +class I18nStore { + locale: Locale = $state(detectInitialLocale()); + + /** + * setLocale changes the active locale. Components reading + * `i18n.t(...)` re-render automatically through the rune. + */ + setLocale(next: Locale): void { + this.locale = next; + } + + /** + * t looks up `key` in the active locale, falling back to the + * default locale when the key is missing. `params` is an optional + * `{name -> value}` map; placeholders in the template (`{name}`) + * are replaced literally with no escaping — callers are expected + * to feed user-safe values. + */ + t(key: TranslationKey, params?: Record): string { + const active = TRANSLATIONS_BY_LOCALE[this.locale]; + const fallback = TRANSLATIONS_BY_LOCALE[DEFAULT_LOCALE]; + const template = active[key] ?? fallback[key] ?? key; + if (params === undefined) { + return template; + } + return template.replace(/\{(\w+)\}/g, (match, name: string) => { + const value = params[name]; + return value === undefined ? match : value; + }); + } + + /** + * resetForTests forces the singleton back to its module-load + * state. Production code never calls this; the Vitest harness + * uses it to keep cases independent. + */ + resetForTests(initial: Locale = detectInitialLocale()): void { + this.locale = initial; + } +} + +export const i18n = new I18nStore(); diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts new file mode 100644 index 0000000..4a6a53e --- /dev/null +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -0,0 +1,40 @@ +// English translation dictionary. Keys are dotted strings grouped +// by feature area (`login.*`, `lobby.*`, `common.*`); values are +// the user-visible text. Adding a new key here also requires adding +// it to every other locale dictionary in this folder, otherwise the +// `t()` helper falls back to English at runtime. + +const en = { + "common.language": "language", + "common.loading": "loading…", + "common.browser_not_supported_title": "browser not supported", + "common.browser_not_supported_body": + "Galaxy requires Ed25519 in WebCrypto. See supported browsers.", + + "login.title": "sign in to Galaxy", + "login.email_label": "email", + "login.email_required": "email must not be empty", + "login.send_code": "send code", + "login.sending": "sending…", + "login.code_label": "code", + "login.code_required": "code must not be empty", + "login.code_sent_to": "code sent to {email}", + "login.verify": "verify", + "login.verifying": "verifying…", + "login.send_new_code": "send a new code", + "login.change_email": "change email", + "login.challenge_expired": + "challenge expired, please request a new code", + "login.code_expired_or_used": + "code expired or already used, please request a new one", + "login.device_key_not_ready": + "device key is not ready, please reload the page", + + "lobby.title": "you are logged in", + "lobby.device_session_id_label": "device session id", + "lobby.greeting": "hello, {name}!", + "lobby.account_loading": "loading account…", + "lobby.logout": "logout", +} as const; + +export default en; diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts new file mode 100644 index 0000000..e0afebd --- /dev/null +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -0,0 +1,41 @@ +// Russian translation dictionary. The keys are identical to the +// English dictionary in `en.ts`; the values are the human Russian +// text. Adding a new key requires updating every locale file in +// this folder so the `t()` helper does not fall back to English. + +import type en from "./en"; + +const ru: Record = { + "common.language": "язык", + "common.loading": "загрузка…", + "common.browser_not_supported_title": "браузер не поддерживается", + "common.browser_not_supported_body": + "Galaxy требует поддержки Ed25519 в WebCrypto. См. список поддерживаемых браузеров.", + + "login.title": "вход в Galaxy", + "login.email_label": "электронная почта", + "login.email_required": "адрес не должен быть пустым", + "login.send_code": "отправить код", + "login.sending": "отправляем…", + "login.code_label": "код", + "login.code_required": "код не должен быть пустым", + "login.code_sent_to": "код отправлен на {email}", + "login.verify": "подтвердить", + "login.verifying": "проверяем…", + "login.send_new_code": "отправить новый код", + "login.change_email": "изменить адрес", + "login.challenge_expired": + "запрос устарел, запросите новый код", + "login.code_expired_or_used": + "код устарел или уже использован, запросите новый", + "login.device_key_not_ready": + "ключ устройства ещё не готов, перезагрузите страницу", + + "lobby.title": "вы вошли в систему", + "lobby.device_session_id_label": "идентификатор сессии устройства", + "lobby.greeting": "здравствуйте, {name}!", + "lobby.account_loading": "загрузка профиля…", + "lobby.logout": "выйти", +}; + +export default ru; diff --git a/ui/frontend/src/routes/+layout.svelte b/ui/frontend/src/routes/+layout.svelte index 90a7eba..52482d0 100644 --- a/ui/frontend/src/routes/+layout.svelte +++ b/ui/frontend/src/routes/+layout.svelte @@ -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"}
-

loading…

+

{i18n.t("common.loading")}

{:else if session.status === "unsupported"}
-

browser not supported

-

- Galaxy requires Ed25519 in WebCrypto. The minimum supported browser - versions are listed in the - storage topic doc. -

+

{i18n.t("common.browser_not_supported_title")}

+

{i18n.t("common.browser_not_supported_body")}

{:else} {@render children()} diff --git a/ui/frontend/src/routes/lobby/+page.svelte b/ui/frontend/src/routes/lobby/+page.svelte index 990b003..690b521 100644 --- a/ui/frontend/src/routes/lobby/+page.svelte +++ b/ui/frontend/src/routes/lobby/+page.svelte @@ -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 @@
-

you are logged in

+

{i18n.t("lobby.title")}

- device session id: {session.deviceSessionId ?? ""} + {i18n.t("lobby.device_session_id_label")}: + {session.deviceSessionId ?? ""}

{#if accountLoading} -

loading account…

+

{i18n.t("lobby.account_loading")}

{:else if displayName !== null} -

- hello, {displayName}! +

+ {i18n.t("lobby.greeting", { name: displayName })}

{:else if accountError !== null}

{accountError}

{/if} - +