ui: plan 01-27 done #1
@@ -2303,7 +2303,13 @@ components:
|
|||||||
format: email
|
format: email
|
||||||
locale:
|
locale:
|
||||||
type: string
|
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:
|
PublicAuthSendEmailCodeResponse:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
+9
-6
@@ -100,12 +100,15 @@ Branches inside backend:
|
|||||||
new one. The client gets the same response shape and is unaware of
|
new one. The client gets the same response shape and is unaware of
|
||||||
the reuse.
|
the reuse.
|
||||||
- **Otherwise.** Backend creates a new challenge with the resolved
|
- **Otherwise.** Backend creates a new challenge with the resolved
|
||||||
preferred language (derived from the optional `Accept-Language`
|
preferred language (derived from the optional `locale` body field
|
||||||
header forwarded by gateway, falling back to a default), and
|
the caller sends — which takes priority — or, if absent or blank,
|
||||||
enqueues the auth-mail row directly into the outbox in the same
|
from the `Accept-Language` header forwarded by gateway, falling
|
||||||
transaction. SMTP delivery is asynchronous; the auth response
|
back to a default), and enqueues the auth-mail row directly into
|
||||||
returns as soon as the challenge and outbox rows are durably
|
the outbox in the same transaction. SMTP delivery is asynchronous;
|
||||||
committed.
|
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
|
### 1.3 Confirming the challenge
|
||||||
|
|
||||||
|
|||||||
@@ -99,11 +99,15 @@ Backend выпускает непрозрачный идентификатор
|
|||||||
backend переиспользует последний имеющийся вызов вместо создания
|
backend переиспользует последний имеющийся вызов вместо создания
|
||||||
нового. Клиент получает ту же форму ответа и не знает о повторе.
|
нового. Клиент получает ту же форму ответа и не знает о повторе.
|
||||||
- **Иначе.** Backend создаёт новый вызов с разрешённым preferred_language
|
- **Иначе.** Backend создаёт новый вызов с разрешённым preferred_language
|
||||||
(выводится из опционального заголовка `Accept-Language`,
|
(выводится из опционального поля `locale` в JSON-теле — оно имеет
|
||||||
форварднутого gateway, с откатом на дефолт) и в той же транзакции
|
приоритет — либо, если оно отсутствует или пустое, из заголовка
|
||||||
ставит auth-mail-строку прямо в outbox. SMTP-доставка асинхронна;
|
`Accept-Language`, форварднутого gateway, с откатом на дефолт) и
|
||||||
auth-ответ возвращается, как только строки challenge и outbox
|
в той же транзакции ставит auth-mail-строку прямо в outbox.
|
||||||
durably закоммитены.
|
SMTP-доставка асинхронна; auth-ответ возвращается, как только
|
||||||
|
строки challenge и outbox durably закоммитены. Поле в теле — это
|
||||||
|
канонический канал, потому что Safari молча сбрасывает выставляемые
|
||||||
|
из JS заголовки `Accept-Language`; клиентам не на Safari достаточно
|
||||||
|
одного заголовка.
|
||||||
|
|
||||||
### 1.3 Подтверждение вызова
|
### 1.3 Подтверждение вызова
|
||||||
|
|
||||||
|
|||||||
@@ -56,9 +56,16 @@ type SendEmailCodeInput struct {
|
|||||||
// code challenge.
|
// code challenge.
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|
||||||
// PreferredLanguage stores the canonical BCP 47 language tag derived from
|
// Locale is the optional BCP 47 language tag the caller wants the
|
||||||
// the public Accept-Language header for upstream auth-mail localization and
|
// auth-mail in. The body field is the canonical channel because Safari
|
||||||
// create-only user registration context.
|
// 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:"-"`
|
PreferredLanguage string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +216,15 @@ func handleSendEmailCode(authService AuthServiceClient, timeout time.Duration) g
|
|||||||
abortInvalidRequest(c, err.Error())
|
abortInvalidRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 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"))
|
input.PreferredLanguage = resolvePreferredLanguage(c.Request.Header.Get("Accept-Language"))
|
||||||
|
}
|
||||||
|
|
||||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -52,6 +52,64 @@ func TestSendEmailCodeHandlerSuccess(t *testing.T) {
|
|||||||
assert.Equal(t, PublicRouteClassPublicAuth, authService.sendEmailCodeRouteClass)
|
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) {
|
func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -134,10 +134,12 @@ paths:
|
|||||||
that must later be confirmed through
|
that must later be confirmed through
|
||||||
`POST /api/v1/public/auth/confirm-email-code`.
|
`POST /api/v1/public/auth/confirm-email-code`.
|
||||||
|
|
||||||
The JSON body stays unchanged. Callers may additionally supply the
|
Callers select the auth-mail locale through the optional
|
||||||
standard `Accept-Language` header so the gateway can derive the
|
`locale` field on the JSON body, which takes priority over the
|
||||||
auth-mail locale and first-login preferred-language candidate. Missing
|
request `Accept-Language` header. The body field is the canonical
|
||||||
or unsupported values fall back to `en`.
|
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`.
|
This route is unauthenticated and classified as `public_auth`.
|
||||||
Public REST anti-abuse applies a per-IP bucket derived from
|
Public REST anti-abuse applies a per-IP bucket derived from
|
||||||
@@ -302,6 +304,16 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Single client e-mail address that should receive the login code.
|
description: Single client e-mail address that should receive the login code.
|
||||||
format: email
|
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:
|
SendEmailCodeResponse:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ ui/
|
|||||||
├── buf.gen.yaml local-plugin TS Protobuf-ES generator
|
├── buf.gen.yaml local-plugin TS Protobuf-ES generator
|
||||||
├── docs/ topic-based design notes
|
├── docs/ topic-based design notes
|
||||||
│ ├── auth-flow.md email-code login, session store, revocation
|
│ ├── 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
|
│ ├── storage.md web KeyStore/Cache, IDB schema, baseline
|
||||||
│ ├── testing.md per-PR / release test tiers
|
│ ├── testing.md per-PR / release test tiers
|
||||||
│ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget
|
│ └── 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,
|
- [`docs/auth-flow.md`](docs/auth-flow.md) — email-code login,
|
||||||
session store state machine, revocation watcher.
|
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,
|
- [`docs/storage.md`](docs/storage.md) — web KeyStore/Cache,
|
||||||
IndexedDB schema, browser baseline.
|
IndexedDB schema, browser baseline.
|
||||||
- [`docs/wasm-toolchain.md`](docs/wasm-toolchain.md) — TinyGo build,
|
- [`docs/wasm-toolchain.md`](docs/wasm-toolchain.md) — TinyGo build,
|
||||||
|
|||||||
@@ -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
|
event from backend, and the watcher reacts on the next event-loop
|
||||||
tick.
|
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
|
## Configuration
|
||||||
|
|
||||||
Build-time environment, read by `lib/env.ts`:
|
Build-time environment, read by `lib/env.ts`:
|
||||||
|
|||||||
+143
@@ -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/<bcp47-primary-subtag>.ts` mirroring the shape of
|
||||||
|
`locales/en.ts`. The TypeScript signature on each non-English
|
||||||
|
file is `Record<keyof typeof en, string>`, 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<keyof typeof en, string> = {
|
||||||
|
"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).
|
||||||
@@ -22,6 +22,20 @@ export interface SendEmailCodeResult {
|
|||||||
challengeId: string;
|
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 {
|
export interface ConfirmEmailCodeInput {
|
||||||
challengeId: string;
|
challengeId: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -61,24 +75,32 @@ export class AuthError extends Error {
|
|||||||
export async function sendEmailCode(
|
export async function sendEmailCode(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
email: string,
|
email: string,
|
||||||
|
options?: SendEmailCodeOptions,
|
||||||
): Promise<SendEmailCodeResult> {
|
): Promise<SendEmailCodeResult> {
|
||||||
|
const requestBody: Record<string, string> = { email };
|
||||||
|
if (options?.locale !== undefined && options.locale !== "") {
|
||||||
|
requestBody.locale = options.locale;
|
||||||
|
}
|
||||||
const response = await fetch(joinUrl(baseUrl, SEND_EMAIL_CODE_PATH), {
|
const response = await fetch(joinUrl(baseUrl, SEND_EMAIL_CODE_PATH), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw await readAuthError(response);
|
throw await readAuthError(response);
|
||||||
}
|
}
|
||||||
const body = (await response.json()) as { challenge_id?: unknown };
|
const responseBody = (await response.json()) as { challenge_id?: unknown };
|
||||||
if (typeof body.challenge_id !== "string" || body.challenge_id.length === 0) {
|
if (
|
||||||
|
typeof responseBody.challenge_id !== "string" ||
|
||||||
|
responseBody.challenge_id.length === 0
|
||||||
|
) {
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
"internal_error",
|
"internal_error",
|
||||||
"gateway returned a malformed send-email-code response",
|
"gateway returned a malformed send-email-code response",
|
||||||
response.status,
|
response.status,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { challengeId: body.challenge_id };
|
return { challengeId: responseBody.challenge_id };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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/<bcp47>.ts` file mirroring the shape of
|
||||||
|
// `locales/en.ts` (TypeScript enforces matching keys via the
|
||||||
|
// `Record<keyof typeof en, string>` 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:
|
||||||
|
// `<p>{i18n.t('login.title')}</p>` 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<Record<TranslationKey, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Record<TranslationKey, string>>
|
||||||
|
> = SUPPORTED_LOCALES.reduce(
|
||||||
|
(acc, entry) => {
|
||||||
|
acc[entry.code] = entry.translations;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<Locale, Readonly<Record<TranslationKey, string>>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, string>): 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();
|
||||||
@@ -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;
|
||||||
@@ -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<keyof typeof en, string> = {
|
||||||
|
"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;
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
import { startRevocationWatcher } from "$lib/revocation-watcher";
|
import { startRevocationWatcher } from "$lib/revocation-watcher";
|
||||||
|
|
||||||
@@ -45,18 +46,12 @@
|
|||||||
|
|
||||||
{#if session.status === "loading"}
|
{#if session.status === "loading"}
|
||||||
<main class="status">
|
<main class="status">
|
||||||
<p>loading…</p>
|
<p>{i18n.t("common.loading")}</p>
|
||||||
</main>
|
</main>
|
||||||
{:else if session.status === "unsupported"}
|
{:else if session.status === "unsupported"}
|
||||||
<main class="status">
|
<main class="status">
|
||||||
<h1>browser not supported</h1>
|
<h1>{i18n.t("common.browser_not_supported_title")}</h1>
|
||||||
<p>
|
<p>{i18n.t("common.browser_not_supported_body")}</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>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { createEdgeGatewayClient } from "../../api/connect";
|
import { createEdgeGatewayClient } from "../../api/connect";
|
||||||
import { GalaxyClient } from "../../api/galaxy-client";
|
import { GalaxyClient } from "../../api/galaxy-client";
|
||||||
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
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 { loadCore } from "../../platform/core/index";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
|
|
||||||
@@ -64,22 +65,23 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>you are logged in</h1>
|
<h1>{i18n.t("lobby.title")}</h1>
|
||||||
<p>
|
<p>
|
||||||
device session id: <code data-testid="device-session-id"
|
{i18n.t("lobby.device_session_id_label")}:
|
||||||
>{session.deviceSessionId ?? ""}</code
|
<code data-testid="device-session-id">{session.deviceSessionId ?? ""}</code>
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
{#if accountLoading}
|
{#if accountLoading}
|
||||||
<p>loading account…</p>
|
<p>{i18n.t("lobby.account_loading")}</p>
|
||||||
{:else if displayName !== null}
|
{:else if displayName !== null}
|
||||||
<p>
|
<p data-testid="account-greeting">
|
||||||
hello, <span data-testid="account-display-name">{displayName}</span>!
|
{i18n.t("lobby.greeting", { name: displayName })}
|
||||||
</p>
|
</p>
|
||||||
{:else if accountError !== null}
|
{:else if accountError !== null}
|
||||||
<p role="alert" data-testid="account-error">{accountError}</p>
|
<p role="alert" data-testid="account-error">{accountError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button onclick={logout} data-testid="lobby-logout">logout</button>
|
<button onclick={logout} data-testid="lobby-logout">
|
||||||
|
{i18n.t("lobby.logout")}
|
||||||
|
</button>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
sendEmailCode,
|
sendEmailCode,
|
||||||
} from "../../api/auth";
|
} from "../../api/auth";
|
||||||
import { GATEWAY_BASE_URL } from "$lib/env";
|
import { GATEWAY_BASE_URL } from "$lib/env";
|
||||||
|
import { i18n, SUPPORTED_LOCALES } from "$lib/i18n/index.svelte";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
|
|
||||||
type Step = "email" | "code";
|
type Step = "email" | "code";
|
||||||
@@ -32,13 +33,15 @@
|
|||||||
if (pending) return;
|
if (pending) return;
|
||||||
const trimmed = email.trim();
|
const trimmed = email.trim();
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
error = "email must not be empty";
|
error = i18n.t("login.email_required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pending = true;
|
pending = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed);
|
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
|
||||||
|
locale: i18n.locale,
|
||||||
|
});
|
||||||
challengeId = result.challengeId;
|
challengeId = result.challengeId;
|
||||||
code = "";
|
code = "";
|
||||||
step = "code";
|
step = "code";
|
||||||
@@ -54,16 +57,16 @@
|
|||||||
if (pending) return;
|
if (pending) return;
|
||||||
const trimmedCode = code.trim();
|
const trimmedCode = code.trim();
|
||||||
if (trimmedCode.length === 0) {
|
if (trimmedCode.length === 0) {
|
||||||
error = "code must not be empty";
|
error = i18n.t("login.code_required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (challengeId === null) {
|
if (challengeId === null) {
|
||||||
error = "challenge expired, please request a new code";
|
error = i18n.t("login.challenge_expired");
|
||||||
step = "email";
|
step = "email";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (session.keypair === null) {
|
if (session.keypair === null) {
|
||||||
error = "device key is not ready, please reload the page";
|
error = i18n.t("login.device_key_not_ready");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pending = true;
|
pending = true;
|
||||||
@@ -82,7 +85,7 @@
|
|||||||
challengeId = null;
|
challengeId = null;
|
||||||
code = "";
|
code = "";
|
||||||
step = "email";
|
step = "email";
|
||||||
error = "code expired or already used, please request a new one";
|
error = i18n.t("login.code_expired_or_used");
|
||||||
} else {
|
} else {
|
||||||
error = describe(err);
|
error = describe(err);
|
||||||
}
|
}
|
||||||
@@ -101,7 +104,9 @@
|
|||||||
pending = true;
|
pending = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed);
|
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
|
||||||
|
locale: i18n.locale,
|
||||||
|
});
|
||||||
challengeId = result.challengeId;
|
challengeId = result.challengeId;
|
||||||
code = "";
|
code = "";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -120,16 +125,68 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<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"}
|
{#if step === "email"}
|
||||||
<form onsubmit={submitEmail} aria-busy={pending}>
|
<form
|
||||||
|
onsubmit={submitEmail}
|
||||||
|
aria-busy={pending}
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
<label>
|
<label>
|
||||||
email
|
{i18n.t("login.email_label")}
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="galaxy-login-email"
|
||||||
autocomplete="email"
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
bind:value={email}
|
bind:value={email}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
required
|
required
|
||||||
@@ -141,19 +198,28 @@
|
|||||||
disabled={pending}
|
disabled={pending}
|
||||||
data-testid="login-email-submit"
|
data-testid="login-email-submit"
|
||||||
>
|
>
|
||||||
{pending ? "sending…" : "send code"}
|
{pending ? i18n.t("login.sending") : i18n.t("login.send_code")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<form onsubmit={submitCode} aria-busy={pending}>
|
<form
|
||||||
<p data-testid="login-code-target">code sent to {email}</p>
|
onsubmit={submitCode}
|
||||||
|
aria-busy={pending}
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<p data-testid="login-code-target">
|
||||||
|
{i18n.t("login.code_sent_to", { email })}
|
||||||
|
</p>
|
||||||
<label>
|
<label>
|
||||||
code
|
{i18n.t("login.code_label")}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="code"
|
name="galaxy-login-code"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
autocomplete="one-time-code"
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
bind:value={code}
|
bind:value={code}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
required
|
required
|
||||||
@@ -161,7 +227,7 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={pending} data-testid="login-code-submit">
|
<button type="submit" disabled={pending} data-testid="login-code-submit">
|
||||||
{pending ? "verifying…" : "verify"}
|
{pending ? i18n.t("login.verifying") : i18n.t("login.verify")}
|
||||||
</button>
|
</button>
|
||||||
<div class="secondary">
|
<div class="secondary">
|
||||||
<button
|
<button
|
||||||
@@ -170,7 +236,7 @@
|
|||||||
disabled={pending}
|
disabled={pending}
|
||||||
data-testid="login-resend"
|
data-testid="login-resend"
|
||||||
>
|
>
|
||||||
send a new code
|
{i18n.t("login.send_new_code")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -178,7 +244,7 @@
|
|||||||
disabled={pending}
|
disabled={pending}
|
||||||
data-testid="login-change-email"
|
data-testid="login-change-email"
|
||||||
>
|
>
|
||||||
change email
|
{i18n.t("login.change_email")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -196,6 +262,46 @@
|
|||||||
max-width: 32rem;
|
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 {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -203,7 +309,7 @@
|
|||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
form > label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ describe("sendEmailCode", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("forwards the locale option in the JSON body", async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { challenge_id: "ch-1" }));
|
||||||
|
await sendEmailCode(BASE_URL, "pilot@example.com", { locale: "ru" });
|
||||||
|
const [, init] = fetchSpy.mock.calls[0]!;
|
||||||
|
expect(init?.headers).toEqual({ "content-type": "application/json" });
|
||||||
|
expect(JSON.parse(init?.body as string)).toEqual({
|
||||||
|
email: "pilot@example.com",
|
||||||
|
locale: "ru",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits the locale field when no locale is provided", async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { challenge_id: "ch-1" }));
|
||||||
|
await sendEmailCode(BASE_URL, "pilot@example.com");
|
||||||
|
const [, init] = fetchSpy.mock.calls[0]!;
|
||||||
|
expect(JSON.parse(init?.body as string)).toEqual({
|
||||||
|
email: "pilot@example.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("throws AuthError carrying gateway code and message on 400", async () => {
|
test("throws AuthError carrying gateway code and message on 400", async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(
|
fetchSpy.mockResolvedValueOnce(
|
||||||
jsonResponse(400, {
|
jsonResponse(400, {
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
await expect(page.getByTestId("device-session-id")).toHaveText(
|
await expect(page.getByTestId("device-session-id")).toHaveText(
|
||||||
"dev-test-1",
|
"dev-test-1",
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId("account-display-name")).toHaveText("Pilot");
|
await expect(page.getByTestId("account-greeting")).toContainText("Pilot");
|
||||||
|
|
||||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||||
});
|
});
|
||||||
@@ -153,7 +153,7 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
||||||
await completeLogin(page);
|
await completeLogin(page);
|
||||||
await expect(page.getByTestId("account-display-name")).toBeVisible();
|
await expect(page.getByTestId("account-greeting")).toBeVisible();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page).toHaveURL(/\/lobby$/);
|
await expect(page).toHaveURL(/\/lobby$/);
|
||||||
@@ -169,7 +169,7 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
||||||
await completeLogin(page);
|
await completeLogin(page);
|
||||||
await expect(page.getByTestId("account-display-name")).toBeVisible();
|
await expect(page.getByTestId("account-greeting")).toBeVisible();
|
||||||
|
|
||||||
// Fire all pending SubscribeEvents requests with an empty 200
|
// Fire all pending SubscribeEvents requests with an empty 200
|
||||||
// response. Connect-Web's server-streaming reader sees no frames
|
// response. Connect-Web's server-streaming reader sees no frames
|
||||||
@@ -182,6 +182,48 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
expect(Date.now() - releaseAt).toBeLessThan(1500);
|
expect(Date.now() - releaseAt).toBeLessThan(1500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("language picker switches the form text and forwards the locale", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sendRequests: Array<{ body: Record<string, unknown> }> = [];
|
||||||
|
await page.route(
|
||||||
|
"**/api/v1/public/auth/send-email-code",
|
||||||
|
async (route) => {
|
||||||
|
const raw = route.request().postData() ?? "";
|
||||||
|
sendRequests.push({
|
||||||
|
body: JSON.parse(raw) as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ challenge_id: "ch-test-2" }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page.getByTestId("login-email-submit")).toHaveText(
|
||||||
|
"send code",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByTestId("login-language-select")
|
||||||
|
.selectOption("ru");
|
||||||
|
await expect(page.getByTestId("login-email-submit")).toHaveText(
|
||||||
|
"отправить код",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId("login-email-input").fill("pilot@example.com");
|
||||||
|
await page.getByTestId("login-email-submit").click();
|
||||||
|
await expect(page.getByTestId("login-code-input")).toBeVisible();
|
||||||
|
|
||||||
|
expect(sendRequests).toHaveLength(1);
|
||||||
|
expect(sendRequests[0]!.body).toEqual({
|
||||||
|
email: "pilot@example.com",
|
||||||
|
locale: "ru",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("browser without WebCrypto Ed25519 shows the not-supported blocker", async ({
|
test("browser without WebCrypto Ed25519 shows the not-supported blocker", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Unit tests for the lightweight i18n primitive in
|
||||||
|
// `src/lib/i18n/index.svelte.ts`. The locale singleton is reset
|
||||||
|
// between cases through `resetForTests`; the default constructor
|
||||||
|
// derives the locale from JSDOM's `navigator.language`, so the
|
||||||
|
// reset takes an explicit value when a case needs a deterministic
|
||||||
|
// starting state.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
SUPPORTED_LOCALES,
|
||||||
|
detectInitialLocale,
|
||||||
|
i18n,
|
||||||
|
type Locale,
|
||||||
|
} from "../src/lib/i18n/index.svelte";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.resetForTests("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
i18n.resetForTests("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detectInitialLocale", () => {
|
||||||
|
test("matches the first supported primary subtag", () => {
|
||||||
|
expect(detectInitialLocale(["ru-RU", "en-US"])).toBe("ru");
|
||||||
|
expect(detectInitialLocale(["en-GB", "ru"])).toBe("en");
|
||||||
|
expect(detectInitialLocale(["RU"])).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips unsupported tags and falls back to the default locale", () => {
|
||||||
|
expect(detectInitialLocale(["fr-FR", "de-DE", "ja"])).toBe(DEFAULT_LOCALE);
|
||||||
|
expect(detectInitialLocale([])).toBe(DEFAULT_LOCALE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores whitespace and casing", () => {
|
||||||
|
expect(detectInitialLocale([" En-us "])).toBe("en");
|
||||||
|
expect(detectInitialLocale(["RU_ru"])).toBe("ru");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SUPPORTED_LOCALES", () => {
|
||||||
|
test("each entry exposes a code, native name and translation table", () => {
|
||||||
|
for (const entry of SUPPORTED_LOCALES) {
|
||||||
|
expect(entry.code).toMatch(/^[a-z]{2}$/);
|
||||||
|
expect(entry.nativeName.length).toBeGreaterThan(0);
|
||||||
|
expect(entry.translations["login.title"].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native names cover the two phase-7 locales", () => {
|
||||||
|
const codes = SUPPORTED_LOCALES.map((l) => l.code);
|
||||||
|
const names = SUPPORTED_LOCALES.map((l) => l.nativeName);
|
||||||
|
expect(codes).toEqual(["en", "ru"]);
|
||||||
|
expect(names).toEqual(["English", "Русский"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("i18n.t", () => {
|
||||||
|
test("returns the active locale's translation", () => {
|
||||||
|
i18n.setLocale("en");
|
||||||
|
expect(i18n.t("login.title")).toBe("sign in to Galaxy");
|
||||||
|
i18n.setLocale("ru");
|
||||||
|
expect(i18n.t("login.title")).toBe("вход в Galaxy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the key itself for an unknown identifier", () => {
|
||||||
|
// `t` is typed `TranslationKey`, but bracket access at runtime
|
||||||
|
// gracefully handles unknown keys — the fallback chain tries
|
||||||
|
// the active locale, then the default locale, then the literal
|
||||||
|
// key. This is the safety net for a future locale that adds
|
||||||
|
// keys before another locale catches up.
|
||||||
|
i18n.setLocale("en");
|
||||||
|
expect(
|
||||||
|
i18n.t("does.not.exist" as unknown as Parameters<typeof i18n.t>[0]),
|
||||||
|
).toBe("does.not.exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interpolates {placeholder} parameters", () => {
|
||||||
|
i18n.setLocale("en");
|
||||||
|
expect(i18n.t("login.code_sent_to", { email: "x@y.z" })).toBe(
|
||||||
|
"code sent to x@y.z",
|
||||||
|
);
|
||||||
|
i18n.setLocale("ru");
|
||||||
|
expect(i18n.t("login.code_sent_to", { email: "x@y.z" })).toBe(
|
||||||
|
"код отправлен на x@y.z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves unresolved placeholders intact", () => {
|
||||||
|
i18n.setLocale("en");
|
||||||
|
expect(i18n.t("login.code_sent_to")).toContain("{email}");
|
||||||
|
expect(i18n.t("login.code_sent_to", { other: "x" })).toContain(
|
||||||
|
"{email}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("i18n.setLocale", () => {
|
||||||
|
test("setLocale updates the reactive state", () => {
|
||||||
|
i18n.setLocale("en");
|
||||||
|
expect(i18n.locale).toBe<Locale>("en");
|
||||||
|
i18n.setLocale("ru");
|
||||||
|
expect(i18n.locale).toBe<Locale>("ru");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import type { IDBPDatabase } from "idb";
|
import type { IDBPDatabase } from "idb";
|
||||||
|
|
||||||
import { AuthError } from "../src/api/auth";
|
import { AuthError } from "../src/api/auth";
|
||||||
|
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||||
import { session } from "../src/lib/session-store.svelte";
|
import { session } from "../src/lib/session-store.svelte";
|
||||||
import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
|
import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
|
||||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
@@ -54,6 +55,7 @@ beforeEach(async () => {
|
|||||||
session.resetForTests();
|
session.resetForTests();
|
||||||
session.setStoreLoaderForTests(async () => store);
|
session.setStoreLoaderForTests(async () => store);
|
||||||
await session.init();
|
await session.init();
|
||||||
|
i18n.resetForTests("en");
|
||||||
sendEmailCodeSpy.mockReset();
|
sendEmailCodeSpy.mockReset();
|
||||||
confirmEmailCodeSpy.mockReset();
|
confirmEmailCodeSpy.mockReset();
|
||||||
});
|
});
|
||||||
@@ -62,6 +64,7 @@ afterEach(async () => {
|
|||||||
sendEmailCodeSpy.mockReset();
|
sendEmailCodeSpy.mockReset();
|
||||||
confirmEmailCodeSpy.mockReset();
|
confirmEmailCodeSpy.mockReset();
|
||||||
session.resetForTests();
|
session.resetForTests();
|
||||||
|
i18n.resetForTests("en");
|
||||||
db.close();
|
db.close();
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const req = indexedDB.deleteDatabase(dbName);
|
const req = indexedDB.deleteDatabase(dbName);
|
||||||
@@ -91,6 +94,7 @@ describe("login page", () => {
|
|||||||
expect(sendEmailCodeSpy).toHaveBeenCalledWith(
|
expect(sendEmailCodeSpy).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
"pilot@example.com",
|
"pilot@example.com",
|
||||||
|
{ locale: "en" },
|
||||||
);
|
);
|
||||||
expect(ui.getByTestId("login-code-input")).toBeInTheDocument();
|
expect(ui.getByTestId("login-code-input")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -222,4 +226,65 @@ describe("login page", () => {
|
|||||||
expect(ui.getByTestId("login-email-input")).toBeInTheDocument();
|
expect(ui.getByTestId("login-email-input")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders the language picker with native names", async () => {
|
||||||
|
const Page = (await importLoginPage()).default;
|
||||||
|
const ui = render(Page);
|
||||||
|
|
||||||
|
const select = ui.getByTestId(
|
||||||
|
"login-language-select",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const options = Array.from(select.options).map((o) => ({
|
||||||
|
value: o.value,
|
||||||
|
text: o.textContent?.trim() ?? "",
|
||||||
|
}));
|
||||||
|
expect(options).toEqual([
|
||||||
|
{ value: "en", text: "English" },
|
||||||
|
{ value: "ru", text: "Русский" },
|
||||||
|
]);
|
||||||
|
expect(select.value).toBe("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching the language re-renders the form text in place", async () => {
|
||||||
|
const Page = (await importLoginPage()).default;
|
||||||
|
const ui = render(Page);
|
||||||
|
|
||||||
|
expect(ui.getByTestId("login-email-submit")).toHaveTextContent(
|
||||||
|
"send code",
|
||||||
|
);
|
||||||
|
const select = ui.getByTestId(
|
||||||
|
"login-language-select",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await fireEvent.change(select, { target: { value: "ru" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(ui.getByTestId("login-email-submit")).toHaveTextContent(
|
||||||
|
"отправить код",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(i18n.locale).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sendEmailCode receives the active locale", async () => {
|
||||||
|
sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" });
|
||||||
|
const Page = (await importLoginPage()).default;
|
||||||
|
const ui = render(Page);
|
||||||
|
|
||||||
|
const select = ui.getByTestId(
|
||||||
|
"login-language-select",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await fireEvent.change(select, { target: { value: "ru" } });
|
||||||
|
|
||||||
|
await fireEvent.input(ui.getByTestId("login-email-input"), {
|
||||||
|
target: { value: "pilot@example.com" },
|
||||||
|
});
|
||||||
|
await fireEvent.click(ui.getByTestId("login-email-submit"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sendEmailCodeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
const args = sendEmailCodeSpy.mock.calls[0]!;
|
||||||
|
expect(args[1]).toBe("pilot@example.com");
|
||||||
|
expect(args[2]).toEqual({ locale: "ru" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user