diff --git a/ui/docs/lobby.md b/ui/docs/lobby.md index 634134c..f785ce8 100644 --- a/ui/docs/lobby.md +++ b/ui/docs/lobby.md @@ -2,16 +2,48 @@ The lobby is the first authenticated view; the user lands here after the email-code login completes (see -[`docs/auth-flow.md`](auth-flow.md)). This doc captures the -sections, the application / invite lifecycle the user sees, and -the defaults baked into the create-game form. +[`docs/auth-flow.md`](auth-flow.md)). This doc captures the shared +shell, the Overview sections, the profile sub-screen, and the +defaults baked into the create-game form. -## Sections +## Shell -The lobby renders one column of sections, top to bottom, with the -common content max-width capped at `32rem` (same convention as the -login page). Cards inside each section take the full available -width. +Lobby and profile share a single chrome implemented in +`lib/screens/lobby-shell.svelte`. The chrome mirrors the project +site's VitePress layout: a left page-list sidebar (Overview / +Profile), a top identity strip on the right, and the page content in +the right-hand column. The shell uses `var(--font-mono)` so the +post-login pages adopt the "nerdy" type stack that the public site +already uses. + +The identity strip reads the caller's account from +`lib/account-store.svelte.ts` — a session-wide cache that fetches +`user.account.get` once on first access and is written through after +every Profile save. Both `lobby-screen.svelte` and +`profile-screen.svelte` populate the same cache through +`account.ensure(client)`, so switching Overview ⇄ Profile never +re-issues `user.account.get` and the strip never flashes the +`lobby.account_loading` placeholder mid-navigation. The cache is +cleared by `session.signOut("user")` / `signOut("revoked")` so a +different user signing in on the same browser does not briefly see +the previous identity. + +The strip falls back to `display_name` → immutable `user_name` → +`lobby.account_loading` while the first `ensure(...)` resolves. It +renders as a `data-testid="lobby-account-name"` button; clicking it +switches the top-level screen to `profile` +(`appScreen.go("profile")`). The e2e suites use that testid as their +lobby-loaded signal. The logout button sits next to it +(`session.signOut("user")`). + +The sidebar always renders both pages; clicking the active page is a +no-op. The shell collapses to a horizontal scrolling strip below +640px. + +## Overview sections + +The Overview page renders one column of sections, top to bottom. +Cards inside each section take the full available width. | Section | Empty state | Source | Action | | -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- | @@ -21,9 +53,37 @@ width. | `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) | | `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) | -The header preserves the device-session-id `` block (kept as -a debug affordance) plus a greeting if the gateway returns a -`display_name` for the caller. +## Profile sub-screen + +`lib/screens/profile-screen.svelte` is a top-level `AppScreen` (peer of +`lobby` and `lobby-create`). The browser Back stack treats it the +same as the create screen — pushing a fresh history entry on entry, +falling back to lobby on Back/Forward (see +[`navigation.md`](navigation.md)). + +On mount it reads the caller's account through `account.ensure(...)` +(see [Shell](#shell)) — the first visit issues `user.account.get`, +subsequent visits resolve from the session-wide cache without a +gateway round-trip. The form renders an identity read-out (immutable +`user_name`, `email`) plus three editable fields: + +| Field | Endpoint | Notes | +| --------------------- | --------------------- | -------------------------------------------------------------- | +| `display_name` | `user.profile.update` | Trimmed; empty value clears the stored name (backend PATCH semantics). | +| `preferred_language` | `user.settings.update`| `` of every IANA zone the browser knows (`Intl.supportedValuesOf("timeZone")`), grouped by leading slash segment (Africa / America / …; singletons like `UTC` collapse into a trailing "Other" optgroup). When the form opens with no stored zone, the picker is pre-selected to `Intl.DateTimeFormat().resolvedOptions().timeZone`. A stored value the runtime no longer advertises is added as an extra "Other" entry so the round-trip never silently drops it. Browsers that lack `supportedValuesOf` fall back to a free-text input; the backend validates with `time.LoadLocation` in every shape. | + +Save fires `user.profile.update` and/or `user.settings.update` +conditionally on which fields actually changed, then **stays on the +profile** and surfaces a transient `profile-saved-notice` line +(`data-testid="profile-saved-notice"`). Editing any field clears the +notice. Only the explicit `cancel` button navigates back to the lobby +(`appScreen.go("lobby")`). When the saved `preferred_language` is one +the UI also ships translations for, the active i18n locale switches +in-place so the rest of the session matches the new preference. The +write-through is also pushed into the shared `account` store so the +shell identity strip picks up the new `display_name` without a second +`user.account.get`. `GameSummary` carries a `current_turn` field that the lobby UI does not display directly — the in-game shell reads it from the same diff --git a/ui/docs/navigation.md b/ui/docs/navigation.md index 141fe90..25ad1da 100644 --- a/ui/docs/navigation.md +++ b/ui/docs/navigation.md @@ -18,9 +18,9 @@ for the whole session. The only other routes are the dev/test-only rune singletons in `src/lib/app-nav.svelte.ts`: - **`appScreen`** — the top-level screen - (`login` / `lobby` / `lobby-create` / `game`) plus the active - `gameId`. It replaces the old `goto`-based redirects and the `[id]` - route param. + (`login` / `lobby` / `lobby-create` / `profile` / `game`) plus the + active `gameId`. It replaces the old `goto`-based redirects and the + `[id]` route param. - **`activeView`** — the in-game view (`map` / `table` / `report` / `battle` / `mail` / `designer-science`) plus the sub-parameters the old route segments carried (`tableEntity`, `battleId`, `turn`, @@ -31,8 +31,11 @@ render: it gates on `session.status` (anonymous → login, authenticated → the `appScreen.screen`), and for the authenticated tree mounts the matching screen component from `src/lib/screens/` (`login-screen.svelte`, `lobby-screen.svelte`, -`lobby-create-screen.svelte`) or, for `screen === "game"`, the in-game -shell `src/lib/game/game-shell.svelte`. The game shell in turn renders +`lobby-create-screen.svelte`, `profile-screen.svelte`) or, for +`screen === "game"`, the in-game shell +`src/lib/game/game-shell.svelte`. Lobby and profile share a +post-login chrome (sidebar + identity strip) implemented in +`lib/screens/lobby-shell.svelte`; see [`lobby.md`](lobby.md). The game shell in turn renders the active view from `activeView` (see below). Navigation is `appScreen.go(screen, { gameId })` and `activeView.select(view, params)` — never `goto`. @@ -73,8 +76,9 @@ Browser **Back/Forward move between screens**, not views, and they do so without ever changing the URL. The shell layers screen history on top of `appScreen` via SvelteKit shallow routing: `appScreen.go(...)` calls `pushState("", { screen, gameId })` for the overlay screens -(`game`, `lobby-create`) and `replaceState(...)` for `lobby` / `login`, -so browser **Back from a game returns to the lobby** beneath it. On +(`game`, `lobby-create`, `profile`) and `replaceState(...)` for +`lobby` / `login`, so browser **Back from a game (or profile) returns +to the lobby** beneath it. On the first authenticated render the dispatcher stamps the restored overlay on top of the load entry, then mirrors `page.state` back into the store on every popstate through `appScreen.syncFromHistory(...)`. diff --git a/ui/frontend/src/api/account.ts b/ui/frontend/src/api/account.ts new file mode 100644 index 0000000..ec0f1d5 --- /dev/null +++ b/ui/frontend/src/api/account.ts @@ -0,0 +1,132 @@ +// Typed wrappers around `GalaxyClient.executeCommand` for the user- +// account command catalog. Each wrapper builds a FlatBuffers request +// payload via the generated TS bindings, calls `executeCommand`, then +// decodes the `AccountResponse` reply. Errors with a non-`ok` +// `result_code` surface as a thrown `AccountError` carrying the +// canonical backend code (`invalid_request`, `subject_not_found`, +// `forbidden`, `conflict`, `internal_error`). + +import { Builder, ByteBuffer } from "flatbuffers"; + +import type { GalaxyClient } from "./galaxy-client"; +import { + AccountResponse, + AccountView, + ErrorResponse as FbsErrorResponse, + GetMyAccountRequest, + UpdateMyProfileRequest, + UpdateMySettingsRequest, +} from "../proto/galaxy/fbs/user"; + +const RESULT_CODE_OK = "ok"; + +export class AccountError extends Error { + readonly code: string; + readonly resultCode: string; + + constructor(resultCode: string, code: string, message: string) { + super(message); + this.name = "AccountError"; + this.resultCode = resultCode; + this.code = code; + } +} + +export interface Account { + userId: string; + email: string; + userName: string; + displayName: string; + preferredLanguage: string; + timeZone: string; + declaredCountry: string; +} + +export async function getMyAccount(client: GalaxyClient): Promise { + const builder = new Builder(32); + GetMyAccountRequest.startGetMyAccountRequest(builder); + builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder)); + const payload = await execute(client, "user.account.get", builder.asUint8Array()); + return decodeAccountResponse(payload); +} + +export async function updateMyProfile( + client: GalaxyClient, + displayName: string, +): Promise { + const builder = new Builder(128); + const displayNameOff = builder.createString(displayName); + UpdateMyProfileRequest.startUpdateMyProfileRequest(builder); + UpdateMyProfileRequest.addDisplayName(builder, displayNameOff); + builder.finish(UpdateMyProfileRequest.endUpdateMyProfileRequest(builder)); + const payload = await execute(client, "user.profile.update", builder.asUint8Array()); + return decodeAccountResponse(payload); +} + +export async function updateMySettings( + client: GalaxyClient, + preferredLanguage: string, + timeZone: string, +): Promise { + const builder = new Builder(128); + const preferredLanguageOff = builder.createString(preferredLanguage); + const timeZoneOff = builder.createString(timeZone); + UpdateMySettingsRequest.startUpdateMySettingsRequest(builder); + UpdateMySettingsRequest.addPreferredLanguage(builder, preferredLanguageOff); + UpdateMySettingsRequest.addTimeZone(builder, timeZoneOff); + builder.finish(UpdateMySettingsRequest.endUpdateMySettingsRequest(builder)); + const payload = await execute(client, "user.settings.update", builder.asUint8Array()); + return decodeAccountResponse(payload); +} + +async function execute( + client: GalaxyClient, + messageType: string, + payloadBytes: Uint8Array, +): Promise { + const result = await client.executeCommand(messageType, payloadBytes); + if (result.resultCode !== RESULT_CODE_OK) { + throw decodeAccountError(result.resultCode, result.payloadBytes); + } + return result.payloadBytes; +} + +function decodeAccountError(resultCode: string, payload: Uint8Array): AccountError { + let code = resultCode; + let message = resultCode; + try { + const errorResponse = FbsErrorResponse.getRootAsErrorResponse(new ByteBuffer(payload)); + const body = errorResponse.error(); + if (body) { + code = body.code() ?? resultCode; + message = body.message() ?? resultCode; + } + } catch (_err) { + // fall through with the raw result code + } + return new AccountError(resultCode, code, message); +} + +function decodeAccountResponse(payload: Uint8Array): Account { + if (payload.length === 0) { + throw new AccountError("internal_error", "internal_error", "empty account response"); + } + const response = AccountResponse.getRootAsAccountResponse(new ByteBuffer(payload)); + const view = response.account(); + if (view === null) { + throw new AccountError("internal_error", "internal_error", "account missing in response"); + } + return decodeAccountView(view); +} + +function decodeAccountView(view: AccountView): Account { + return { + userId: view.userId() ?? "", + email: view.email() ?? "", + userName: view.userName() ?? "", + displayName: view.displayName() ?? "", + preferredLanguage: view.preferredLanguage() ?? "", + timeZone: view.timeZone() ?? "", + declaredCountry: view.declaredCountry() ?? "", + }; +} diff --git a/ui/frontend/src/app.d.ts b/ui/frontend/src/app.d.ts index 3b7f826..2fb8952 100644 --- a/ui/frontend/src/app.d.ts +++ b/ui/frontend/src/app.d.ts @@ -7,7 +7,7 @@ declare global { // (and active game) live in `page.state` so browser Back/Forward // move between screens while the address bar stays at /game/. interface PageState { - screen?: "login" | "lobby" | "lobby-create" | "game"; + screen?: "login" | "lobby" | "lobby-create" | "profile" | "game"; gameId?: string | null; } } diff --git a/ui/frontend/src/lib/account-store.svelte.ts b/ui/frontend/src/lib/account-store.svelte.ts new file mode 100644 index 0000000..d56493a --- /dev/null +++ b/ui/frontend/src/lib/account-store.svelte.ts @@ -0,0 +1,76 @@ +// `AccountStore` is the session-wide cache for the caller's +// `user.account.get` aggregate. The lobby shell and every post-login +// screen read the identity (display name, immutable user_name, time +// zone, …) from the same rune, so navigating between Overview and +// Profile does not refetch and does not flash the +// `lobby.account_loading` placeholder. +// +// `ensure(client)` fetches once on first call, dedupes concurrent +// callers onto a single in-flight promise, and resolves immediately +// from the cache thereafter. `set(account)` is the write-through +// path used by Profile after `user.profile.update` / +// `user.settings.update` succeeds — both the shell and the screen +// pick up the change without an extra round-trip. `clear()` resets +// the cache on logout so a different user signing in on the same +// browser does not briefly see the previous identity. +// +// The store is intentionally narrow: it caches one struct, never +// retries on failure (the caller decides), and exposes no error +// state of its own. Callers that need a tighter error surface (the +// Profile form) catch the rejection from `ensure(client)` directly. + +import type { GalaxyClient } from "../api/galaxy-client"; +import { getMyAccount, type Account } from "../api/account"; + +class AccountStore { + current: Account | null = $state(null); + #inFlight: Promise | null = null; + + /** + * ensure returns the cached `Account` when present, otherwise issues + * `user.account.get` through the supplied client and caches the + * result. Concurrent callers during the first fetch share the same + * in-flight promise so the gateway only sees one request per + * session. + */ + ensure(client: GalaxyClient): Promise { + if (this.current !== null) { + return Promise.resolve(this.current); + } + if (this.#inFlight !== null) { + return this.#inFlight; + } + const pending = getMyAccount(client) + .then((account) => { + this.current = account; + return account; + }) + .finally(() => { + this.#inFlight = null; + }); + this.#inFlight = pending; + return pending; + } + + /** + * set replaces the cached `Account` with the supplied value. Used + * by the Profile screen after a successful save so both the form + * and the shell identity strip pick up the new fields without a + * second round-trip. + */ + set(next: Account): void { + this.current = next; + } + + /** + * clear resets the cache. Called on logout so a different user + * signing in on the same browser does not briefly see the + * previous identity through the rune. + */ + clear(): void { + this.current = null; + this.#inFlight = null; + } +} + +export const account = new AccountStore(); diff --git a/ui/frontend/src/lib/app-nav.svelte.ts b/ui/frontend/src/lib/app-nav.svelte.ts index 66107ec..630d7e3 100644 --- a/ui/frontend/src/lib/app-nav.svelte.ts +++ b/ui/frontend/src/lib/app-nav.svelte.ts @@ -22,7 +22,7 @@ import { pushState, replaceState } from "$app/navigation"; -export type AppScreen = "login" | "lobby" | "lobby-create" | "game"; +export type AppScreen = "login" | "lobby" | "lobby-create" | "profile" | "game"; export type GameView = | "map" @@ -51,6 +51,7 @@ const APP_SCREENS: readonly AppScreen[] = [ "login", "lobby", "lobby-create", + "profile", "game", ]; const GAME_VIEWS: readonly GameView[] = [ @@ -183,7 +184,11 @@ class AppScreenStore { #syncHistory(): void { if (typeof window === "undefined") return; const state: App.PageState = { screen: this.#screen, gameId: this.#gameId }; - if (this.#screen === "game" || this.#screen === "lobby-create") { + if ( + this.#screen === "game" || + this.#screen === "lobby-create" || + this.#screen === "profile" + ) { pushState("", state); } else { replaceState("", state); diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 57031a0..e100a50 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -50,11 +50,11 @@ const en = { "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", + "lobby.nav.aria_label": "lobby pages", + "lobby.nav.overview": "Overview", + "lobby.nav.profile": "Profile", "lobby.section.my_games": "my games", "lobby.section.invitations": "pending invitations", "lobby.section.applications": "my applications", @@ -103,6 +103,22 @@ const en = { "lobby.error.internal_error": "internal server error", "lobby.error.unknown": "{message}", + "profile.title": "Profile", + "profile.loading": "loading account…", + "profile.field.user_name": "username", + "profile.field.email": "email", + "profile.field.display_name": "display name", + "profile.field.preferred_language": "preferred language", + "profile.field.time_zone": "time zone", + "profile.hint.display_name": "shown wherever Galaxy needs a friendlier name than the username handle. Leave empty to fall back to the username.", + "profile.hint.time_zone": "IANA zones grouped by continent. The form opens on your browser's current zone when no value is saved.", + "profile.save": "save", + "profile.saving": "saving…", + "profile.saved": "saved", + "profile.cancel": "cancel", + "profile.error.language_required": "language must not be empty", + "profile.error.time_zone_required": "time zone must not be empty", + "game.shell.unknown": "?", "game.shell.connection.online": "online", "game.shell.connection.reconnecting": "reconnecting…", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index b6ac94b..a472fa8 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -51,11 +51,11 @@ const ru: Record = { "login.device_key_not_ready": "ключ устройства ещё не готов, перезагрузите страницу", - "lobby.title": "вы вошли в систему", - "lobby.device_session_id_label": "идентификатор сессии устройства", - "lobby.greeting": "здравствуйте, {name}!", "lobby.account_loading": "загрузка профиля…", "lobby.logout": "выйти", + "lobby.nav.aria_label": "разделы лобби", + "lobby.nav.overview": "Обзор", + "lobby.nav.profile": "Профиль", "lobby.section.my_games": "мои игры", "lobby.section.invitations": "ожидающие приглашения", "lobby.section.applications": "мои заявки", @@ -104,6 +104,22 @@ const ru: Record = { "lobby.error.internal_error": "внутренняя ошибка сервера", "lobby.error.unknown": "{message}", + "profile.title": "Профиль", + "profile.loading": "загрузка профиля…", + "profile.field.user_name": "идентификатор", + "profile.field.email": "электронная почта", + "profile.field.display_name": "отображаемое имя", + "profile.field.preferred_language": "язык интерфейса", + "profile.field.time_zone": "часовой пояс", + "profile.hint.display_name": "показывается там, где нужно более «человеческое» имя, чем системный идентификатор. Пустое значение — вернётся к идентификатору.", + "profile.hint.time_zone": "пояса IANA, сгруппированные по континентам. Если сохранённого значения нет, форма открывается на поясе, который определил браузер.", + "profile.save": "сохранить", + "profile.saving": "сохраняем…", + "profile.saved": "сохранено", + "profile.cancel": "отмена", + "profile.error.language_required": "язык не должен быть пустым", + "profile.error.time_zone_required": "часовой пояс не должен быть пустым", + "game.shell.unknown": "?", "game.shell.connection.online": "онлайн", "game.shell.connection.reconnecting": "переподключение…", diff --git a/ui/frontend/src/lib/screens/lobby-screen.svelte b/ui/frontend/src/lib/screens/lobby-screen.svelte index 5f96132..7c8a0f8 100644 --- a/ui/frontend/src/lib/screens/lobby-screen.svelte +++ b/ui/frontend/src/lib/screens/lobby-screen.svelte @@ -17,8 +17,7 @@ type GameSummary, type InviteSummary, } from "../../api/lobby"; - import { ByteBuffer } from "flatbuffers"; - import { AccountResponse } from "../../proto/galaxy/fbs/user"; + import { account } from "$lib/account-store.svelte"; import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env"; import { SyntheticReportError, @@ -27,10 +26,8 @@ import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { loadCore } from "../../platform/core/index"; import { session } from "$lib/session-store.svelte"; - import { Builder } from "flatbuffers"; - import { GetMyAccountRequest } from "../../proto/galaxy/fbs/user"; + import LobbyShell from "./lobby-shell.svelte"; - let displayName: string | null = $state(null); let configError: string | null = $state(null); let listsLoading = $state(true); let lobbyError: string | null = $state(null); @@ -51,10 +48,6 @@ let client: GalaxyClient | null = null; - async function logout(): Promise { - await session.signOut("user"); - } - async function sha256(payload: Uint8Array): Promise { const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource); return new Uint8Array(digest); @@ -163,26 +156,6 @@ } } - async function loadGreeting(c: GalaxyClient): Promise { - const builder = new Builder(32); - GetMyAccountRequest.startGetMyAccountRequest(builder); - builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder)); - const result = await c.executeCommand("user.account.get", builder.asUint8Array()); - if (result.resultCode !== "ok") { - return; - } - const response = AccountResponse.getRootAsAccountResponse( - new ByteBuffer(result.payloadBytes), - ); - const account = response.account(); - if (account === null) { - return; - } - const display = account.displayName(); - const userName = account.userName(); - displayName = display && display.length > 0 ? display : userName; - } - function gotoCreate(): void { appScreen.go("lobby-create"); } @@ -260,7 +233,11 @@ deviceSessionId: session.deviceSessionId, gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY, }); - loadGreeting(client).catch(() => {}); + // Populate the session-wide identity cache; the shell's + // identity strip reads from there. Swallowed errors leave + // the shell on the `lobby.account_loading` placeholder + // without breaking the rest of the lobby. + account.ensure(client).catch(() => {}); await refreshAll(); } catch (err) { lobbyError = describeLobbyError(err); @@ -269,24 +246,7 @@ }); - -
-
-

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

-

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

- {#if displayName !== null} -

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

- {/if} - -
- + {#if configError !== null}

{configError}

{:else if lobbyError !== null} @@ -483,38 +443,16 @@ {/if} -
+ diff --git a/ui/frontend/src/lib/screens/lobby-shell.svelte b/ui/frontend/src/lib/screens/lobby-shell.svelte new file mode 100644 index 0000000..4fa07cd --- /dev/null +++ b/ui/frontend/src/lib/screens/lobby-shell.svelte @@ -0,0 +1,227 @@ + + + + +
+
+ + +
+
+ +
+ {@render children()} +
+
+
+ + diff --git a/ui/frontend/src/lib/screens/profile-screen.svelte b/ui/frontend/src/lib/screens/profile-screen.svelte new file mode 100644 index 0000000..bb33df9 --- /dev/null +++ b/ui/frontend/src/lib/screens/profile-screen.svelte @@ -0,0 +1,387 @@ + + + +

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

+ {#if configError !== null} +

{configError}

+ {:else if loaded === null && loadError === null} +

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

+ {:else if loadError !== null} +

{loadError}

+ {:else if loaded !== null} +
+
{i18n.t("profile.field.user_name")}
+
{loaded.userName}
+
{i18n.t("profile.field.email")}
+
{loaded.email}
+
+ +
+ + + + + + + {#if saveError !== null} +

{saveError}

+ {:else if savedNotice} +

+ {i18n.t("profile.saved")} +

+ {/if} + +
+ + +
+
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/session-store.svelte.ts b/ui/frontend/src/lib/session-store.svelte.ts index ba4ff1c..f75572c 100644 --- a/ui/frontend/src/lib/session-store.svelte.ts +++ b/ui/frontend/src/lib/session-store.svelte.ts @@ -31,6 +31,7 @@ import { loadDeviceSession, setDeviceSessionId, } from "../api/session"; +import { account } from "./account-store.svelte"; export type SessionStatus = | "loading" @@ -94,6 +95,10 @@ export class SessionStore { this.keypair = fresh.keypair; this.deviceSessionId = null; this.status = "anonymous"; + // Drop the cached identity so a different user signing in on the + // same browser does not briefly see the previous display name + // through the post-login shell. + account.clear(); if (reason === "revoked") { console.info("session store: device session revoked by gateway"); } diff --git a/ui/frontend/src/lib/time-zones.ts b/ui/frontend/src/lib/time-zones.ts new file mode 100644 index 0000000..a6dd3b3 --- /dev/null +++ b/ui/frontend/src/lib/time-zones.ts @@ -0,0 +1,140 @@ +// Time-zone option helpers for the Profile screen's `` can render it without losing the saved + * zone. The original groups are not mutated. + */ +export function withPreservedValue( + groups: readonly TimeZoneGroup[], + value: string, +): readonly TimeZoneGroup[] { + const trimmed = value.trim(); + if (trimmed === "") return groups; + for (const group of groups) { + if (group.values.includes(trimmed)) return groups; + } + const extra: TimeZoneGroup = { label: OTHER_GROUP, values: [trimmed] }; + // Merge with an existing "Other" group if one is already present, + // otherwise append a fresh one. + const next: TimeZoneGroup[] = []; + let mergedIntoOther = false; + for (const group of groups) { + if (group.label === OTHER_GROUP) { + mergedIntoOther = true; + next.push({ + label: OTHER_GROUP, + values: [...group.values, trimmed].sort((a, b) => a.localeCompare(b)), + }); + } else { + next.push(group); + } + } + if (!mergedIntoOther) next.push(extra); + return next; +} + +/** + * browserTimeZone returns the time zone the runtime believes the + * user is in. An empty string is returned when `Intl.DateTimeFormat` + * is missing or rejects the resolution. + */ +export function browserTimeZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""; + } catch { + return ""; + } +} + +interface IntlWithSupportedValues { + supportedValuesOf?: (key: "timeZone") => string[]; +} + +function listSupportedZones(): string[] { + const intl = Intl as unknown as IntlWithSupportedValues; + if (typeof intl.supportedValuesOf !== "function") return []; + try { + const zones = intl.supportedValuesOf("timeZone"); + return Array.isArray(zones) ? zones.slice() : []; + } catch { + return []; + } +} + +function groupZones(zones: readonly string[]): readonly TimeZoneGroup[] { + const buckets = new Map(); + const others: string[] = []; + for (const zone of zones) { + const slash = zone.indexOf("/"); + if (slash === -1) { + others.push(zone); + continue; + } + const prefix = zone.slice(0, slash); + const bucket = buckets.get(prefix); + if (bucket === undefined) { + buckets.set(prefix, [zone]); + } else { + bucket.push(zone); + } + } + const groups: TimeZoneGroup[] = []; + const sortedPrefixes = Array.from(buckets.keys()).sort((a, b) => + a.localeCompare(b), + ); + for (const prefix of sortedPrefixes) { + const values = (buckets.get(prefix) ?? []).slice().sort((a, b) => + a.localeCompare(b), + ); + groups.push({ label: prefix, values }); + } + if (others.length > 0) { + groups.push({ + label: OTHER_GROUP, + values: others.slice().sort((a, b) => a.localeCompare(b)), + }); + } + return groups; +} diff --git a/ui/frontend/src/routes/+page.svelte b/ui/frontend/src/routes/+page.svelte index 182154d..d7505b5 100644 --- a/ui/frontend/src/routes/+page.svelte +++ b/ui/frontend/src/routes/+page.svelte @@ -19,6 +19,7 @@ import LoginScreen from "$lib/screens/login-screen.svelte"; import LobbyScreen from "$lib/screens/lobby-screen.svelte"; import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte"; + import ProfileScreen from "$lib/screens/profile-screen.svelte"; import GameShell from "$lib/game/game-shell.svelte"; import { pushState } from "$app/navigation"; import { page } from "$app/state"; @@ -67,6 +68,8 @@ pushState("", { screen: "game", gameId: appScreen.gameId }); } else if (appScreen.screen === "lobby-create") { pushState("", { screen: "lobby-create" }); + } else if (appScreen.screen === "profile") { + pushState("", { screen: "profile" }); } } }); @@ -83,6 +86,8 @@ {#if session.status === "authenticated"} {#if appScreen.screen === "lobby-create"} + {:else if appScreen.screen === "profile"} + {:else if appScreen.screen === "game" && appScreen.gameId !== null} {:else} diff --git a/ui/frontend/tests/e2e/auth-flow.spec.ts b/ui/frontend/tests/e2e/auth-flow.spec.ts index de3e286..66d9d89 100644 --- a/ui/frontend/tests/e2e/auth-flow.spec.ts +++ b/ui/frontend/tests/e2e/auth-flow.spec.ts @@ -159,9 +159,9 @@ async function completeLogin(page: Page): Promise { await page.getByTestId("login-code-input").click(); await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-submit").click(); - // Sign-in switches the in-memory screen to the lobby; the device - // session id surfaces only on the lobby screen. - await expect(page.getByTestId("device-session-id")).toBeVisible(); + // Sign-in switches the in-memory screen to the lobby; the identity + // strip rendered by `lobby-shell.svelte` is the lobby-loaded signal. + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); } test.describe("Phase 7 — auth flow", () => { @@ -174,10 +174,7 @@ test.describe("Phase 7 — auth flow", () => { }) => { const mocks = await mockGatewayHappyPath(page, "Pilot"); await completeLogin(page); - await expect(page.getByTestId("device-session-id")).toHaveText( - "dev-test-1", - ); - await expect(page.getByTestId("account-greeting")).toContainText("Pilot"); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); @@ -187,13 +184,13 @@ test.describe("Phase 7 — auth flow", () => { }) => { const mocks = await mockGatewayHappyPath(page, "Pilot"); await completeLogin(page); - await expect(page.getByTestId("account-greeting")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); await page.reload(); // The restored session re-renders the lobby screen directly (no // `/lobby` route to land on). - await expect(page.getByTestId("device-session-id")).toHaveText( - "dev-test-1", + await expect(page.getByTestId("lobby-account-name")).toContainText( + "Pilot", ); mocks.pendingSubscribes.forEach((resolve) => resolve()); @@ -204,7 +201,15 @@ test.describe("Phase 7 — auth flow", () => { }) => { const mocks = await mockGatewayHappyPath(page, "Pilot"); await completeLogin(page); - await expect(page.getByTestId("account-greeting")).toBeVisible(); + // `lobby-account-name` becomes visible on lobby mount with the + // "loading account…" placeholder before the gateway responds. + // Wait for the loaded name to settle so the event-stream effect + // has had a chance to issue its `SubscribeEvents` request — the + // release below targets that pending stream, and an empty + // `pendingSubscribes` list defeats the test. + await expect(page.getByTestId("lobby-account-name")).toContainText( + "Pilot", + ); // Fire all pending SubscribeEvents requests with an empty 200 // response. Connect-Web's server-streaming reader sees no frames diff --git a/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts b/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts index 23a0580..9488ede 100644 --- a/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts +++ b/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts @@ -215,6 +215,9 @@ export interface AccountFixture { email: string; userName: string; displayName: string; + preferredLanguage?: string; + timeZone?: string; + declaredCountry?: string; } export function buildAccountResponsePayload(account: AccountFixture): Uint8Array { @@ -237,9 +240,9 @@ export function buildAccountResponsePayload(account: AccountFixture): Uint8Array const email = builder.createString(account.email); const userName = builder.createString(account.userName); const displayName = builder.createString(account.displayName); - const preferredLanguage = builder.createString("en"); - const timeZone = builder.createString("UTC"); - const declaredCountry = builder.createString(""); + const preferredLanguage = builder.createString(account.preferredLanguage ?? "en"); + const timeZone = builder.createString(account.timeZone ?? "UTC"); + const declaredCountry = builder.createString(account.declaredCountry ?? ""); AccountView.startAccountView(builder); AccountView.addUserId(builder, userId); AccountView.addEmail(builder, email); diff --git a/ui/frontend/tests/e2e/lobby-flow.spec.ts b/ui/frontend/tests/e2e/lobby-flow.spec.ts index 958e3e7..7d14cf5 100644 --- a/ui/frontend/tests/e2e/lobby-flow.spec.ts +++ b/ui/frontend/tests/e2e/lobby-flow.spec.ts @@ -251,7 +251,7 @@ async function completeLogin(page: Page): Promise { await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-submit").click(); // Sign-in switches the in-memory screen to the lobby. - await expect(page.getByTestId("device-session-id")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); } test.describe("Phase 8 — lobby flow", () => { diff --git a/ui/frontend/tests/e2e/profile-screen.spec.ts b/ui/frontend/tests/e2e/profile-screen.spec.ts new file mode 100644 index 0000000..a8ff3a8 --- /dev/null +++ b/ui/frontend/tests/e2e/profile-screen.spec.ts @@ -0,0 +1,339 @@ +// F8-04 profile screen — end-to-end coverage. Mocks the gateway so the +// lobby boots with an account aggregate, then exercises the sidebar +// navigation into the profile, the edit form, the save-stay flow, and +// the time-zone dropdown. + +import { fromJson, type JsonValue } from "@bufbuild/protobuf"; +import { ByteBuffer } from "flatbuffers"; +import { expect, test, type Page } from "@playwright/test"; +import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb"; +import { + UpdateMyProfileRequest, + UpdateMySettingsRequest, +} from "../../src/proto/galaxy/fbs/user"; +import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; +import { + buildAccountResponsePayload, + buildMyApplicationsListPayload, + buildMyGamesListPayload, + buildMyInvitesListPayload, + buildPublicGamesListPayload, + type AccountFixture, +} from "./fixtures/lobby-fbs"; + +interface ProfileMocks { + pendingSubscribes: Array<() => void>; + account: AccountFixture; + accountGetCount: number; + profileUpdates: Array<{ displayName: string }>; + settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>; +} + +async function mockGateway( + page: Page, + initial: AccountFixture, +): Promise { + const mocks: ProfileMocks = { + pendingSubscribes: [], + account: { ...initial }, + accountGetCount: 0, + profileUpdates: [], + settingsUpdates: [], + }; + + await page.route("**/api/v1/public/auth/send-email-code", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ challenge_id: "ch-test-1" }), + }); + }); + await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ device_session_id: "dev-test-1" }), + }); + }); + + await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => { + const reqText = route.request().postData(); + if (reqText === null) { + await route.fulfill({ status: 400 }); + return; + } + const req = fromJson( + ExecuteCommandRequestSchema, + JSON.parse(reqText) as JsonValue, + ); + let payload: Uint8Array; + switch (req.messageType) { + case "user.account.get": + mocks.accountGetCount += 1; + payload = buildAccountResponsePayload(mocks.account); + break; + case "user.profile.update": { + const decoded = UpdateMyProfileRequest.getRootAsUpdateMyProfileRequest( + new ByteBuffer(req.payloadBytes), + ); + const next = decoded.displayName() ?? ""; + mocks.profileUpdates.push({ displayName: next }); + mocks.account = { ...mocks.account, displayName: next }; + payload = buildAccountResponsePayload(mocks.account); + break; + } + case "user.settings.update": { + const decoded = UpdateMySettingsRequest.getRootAsUpdateMySettingsRequest( + new ByteBuffer(req.payloadBytes), + ); + const preferredLanguage = decoded.preferredLanguage() ?? ""; + const timeZone = decoded.timeZone() ?? ""; + mocks.settingsUpdates.push({ preferredLanguage, timeZone }); + mocks.account = { ...mocks.account, preferredLanguage, timeZone }; + payload = buildAccountResponsePayload(mocks.account); + break; + } + case "lobby.my.games.list": + payload = buildMyGamesListPayload([]); + break; + case "lobby.public.games.list": + payload = buildPublicGamesListPayload([]); + break; + case "lobby.my.invites.list": + payload = buildMyInvitesListPayload([]); + break; + case "lobby.my.applications.list": + payload = buildMyApplicationsListPayload([]); + break; + default: + payload = new Uint8Array(); + } + const responseJson = await forgeExecuteCommandResponseJson({ + requestId: req.requestId, + timestampMs: BigInt(Date.now()), + resultCode: "ok", + payloadBytes: payload, + }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: responseJson, + }); + }); + + await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => { + const action = await new Promise<"endOfStream" | "abort">((resolve) => { + mocks.pendingSubscribes.push(() => resolve("endOfStream")); + }); + if (action === "abort") { + await route.abort(); + return; + } + const body = new TextEncoder().encode("{}"); + const frame = new Uint8Array(5 + body.length); + frame[0] = 0x02; + new DataView(frame.buffer).setUint32(1, body.length, false); + frame.set(body, 5); + await route.fulfill({ + status: 200, + contentType: "application/connect+json", + body: Buffer.from(frame), + }); + }); + + return mocks; +} + +async function completeLogin(page: Page): Promise { + await page.goto("/"); + await expect(page.getByTestId("login-email-input")).toBeVisible(); + await page.getByTestId("login-email-input").click(); + 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(); + await page.getByTestId("login-code-input").click(); + await page.getByTestId("login-code-input").fill("123456"); + await page.getByTestId("login-code-submit").click(); + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); +} + +test.describe("F8-04 — profile screen", () => { + test("clicking the identity strip opens the profile and renders the form", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); + + await page.getByTestId("lobby-account-name").click(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + await expect(page.getByTestId("profile-display-name")).toHaveValue("Pilot"); + await expect(page.getByTestId("profile-identity")).toContainText( + "player-abc12345", + ); + await expect(page.getByTestId("profile-identity")).toContainText( + "pilot@example.com", + ); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("saving an edited display name posts user.profile.update, stays on the form, and refreshes the identity strip", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await page.getByTestId("lobby-account-name").click(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + + await page.getByTestId("profile-display-name").fill("Captain"); + await page.getByTestId("profile-save").click(); + + // Form stays on screen; the saved notice surfaces and the + // shell-level identity strip picks up the new name without a + // second `user.account.get`. + await expect(page.getByTestId("profile-saved-notice")).toBeVisible(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toContainText( + "Captain", + ); + expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]); + expect(mocks.settingsUpdates).toEqual([]); + + // Editing the form again clears the notice so a follow-up save is + // unambiguous. + await page.getByTestId("profile-display-name").fill("Pilot"); + await expect(page.getByTestId("profile-saved-notice")).toHaveCount(0); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("changing the language posts user.settings.update, stays on the form, and switches the active locale", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await page.getByTestId("lobby-account-name").click(); + + await page.getByTestId("profile-preferred-language").selectOption("ru"); + await page.getByTestId("profile-save").click(); + + // Profile stays on screen; the Russian dictionary now drives the + // form copy. The save button label is the visible signal. + await expect(page.getByTestId("profile-form")).toBeVisible(); + await expect(page.getByTestId("profile-save")).toHaveText("сохранить"); + await expect(page.getByTestId("profile-saved-notice")).toBeVisible(); + expect(mocks.settingsUpdates).toHaveLength(1); + expect(mocks.settingsUpdates[0]?.preferredLanguage).toBe("ru"); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("cancel returns to the lobby without posting anything", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await page.getByTestId("lobby-account-name").click(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + + await page.getByTestId("profile-display-name").fill("ignored"); + await page.getByTestId("profile-cancel").click(); + + await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); + expect(mocks.profileUpdates).toEqual([]); + expect(mocks.settingsUpdates).toEqual([]); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("time zone is a continent-grouped with at least the Europe and + // America optgroups present and the stored zone selected. + expect(await select.evaluate((el) => el.tagName)).toBe("SELECT"); + const optgroupLabels = await select.evaluate((el) => + Array.from((el as HTMLSelectElement).querySelectorAll("optgroup")).map( + (g) => g.label, + ), + ); + expect(optgroupLabels).toContain("Europe"); + expect(optgroupLabels).toContain("America"); + await expect(select).toHaveValue("Europe/London"); + + await select.selectOption("America/New_York"); + await page.getByTestId("profile-save").click(); + + await expect(page.getByTestId("profile-saved-notice")).toBeVisible(); + expect(mocks.settingsUpdates).toEqual([ + { preferredLanguage: "en", timeZone: "America/New_York" }, + ]); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("the identity strip persists across Overview ⇄ Profile without a second user.account.get", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); + const firstCount = mocks.accountGetCount; + expect(firstCount).toBeGreaterThanOrEqual(1); + + // Navigate Overview → Profile: identity must NOT flash the + // loading placeholder, and the cache must answer without a + // second gateway call. + await page.getByTestId("lobby-nav-profile").click(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); + + // Navigate back to Overview. + await page.getByTestId("lobby-nav-overview").click(); + await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); + + expect(mocks.accountGetCount).toBe(firstCount); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); +});