diff --git a/.changeset/30206-first-run-checklist.md b/.changeset/30206-first-run-checklist.md new file mode 100644 index 00000000000..eb3f0975ed8 --- /dev/null +++ b/.changeset/30206-first-run-checklist.md @@ -0,0 +1,30 @@ +--- +'@object-ui/console': patch +'@object-ui/i18n': patch +--- + +feat(console)!: an administrator's first-run checklist on Home replaces the currency and time zone dialog, and a country typed by name is saved as its code (#30206) + +Clause-②: yes (narrowing) — the published `@object-ui/i18n` packs (all ten) drop the `console.workspaceTimezonePrompt.*` keys, which had no reader outside the removed dialog. The widening half: new console i18n keys `console.firstRunChecklist.*` and `console.settingsView.invalidFormat` / `.invalidFormatHelp` / `.invalidFormatPick` (all ten packs). +Metadata-protocol: none + + + +The dialog that asked for the system timezone and default currency opened over the first page an administrator visited, before anything needed a currency, and once covered the toast that said how to share an invitation. It is gone. The same questions, and the other things a new workspace needs, are now rows of a checklist at the top of Home (`/home`): + +1. **Organization name**: done once the organization no longer carries the name the platform gave it ("Default Organization"). Links to Setup → Organization. +2. **Default language and country**, 3. **System timezone**, 4. **Default currency**: done once the setting is set (for language, country and timezone, set by someone rather than left at the platform default; for the currency, which has no default, holding any value). Each opens in place with the Settings page's own fields, pre-filled from the browser: language, country and currency from the browser's first language (so `zh-CN` offers 中文, China and CNY together), the timezone from the browser's zone. Nothing is written until the row's Confirm, which saves through the Settings page's own `PUT /api/settings/localization`. +5. **Email delivery**: done once mail can deliver (a provider other than `log`, a From address, and a host for SMTP). Shown only to a session that may write mail. Links to Setup → Mail Delivery. +6. **Invite colleagues**: done once there is a second member or a pending invitation. Links to Setup → Users. + +Each row's state is read from the real setting every time, never from a click. The checklist is shown only to a session that may write `localization`; a member sees nothing, and a member's Home sends no settings namespace read. It draws nothing once every row is done, and its close button hides it for that administrator in that workspace on that device, writing nothing to the server. The rules the dialog was built on still hold: the browser's zone and currency are never written without the administrator's Confirm, and an unset currency stays on the list until it is set. + +**BREAKING** (`@object-ui/i18n`, TypeScript only): the exported `TranslationKeys` (`typeof en`) loses the 14 `console.workspaceTimezonePrompt.*` keys, in all ten packs (ar, de, en, es, fr, ja, ko, pt, ru, zh). A `t()` call that names one still compiles, since its key is a `string`, and reads the key back. Nothing replaces them one for one; the fix is to delete the reference. + +| Removed key | Replacement | +|:---|:---| +| `console.workspaceTimezonePrompt.title`, `.description`, `.laterHint`, `.decline`, `.confirm`, `.saved`, `.notNow` | none: the dialog is retired; the timezone is a row of `console.firstRunChecklist` (`.timezone.*`) | +| `console.workspaceTimezonePrompt.currencyTitle`, `.currencyDescription`, `.currencyConfirm`, `.currencySaved` | none: the currency is a row of `console.firstRunChecklist` (`.currency.*`) | +| `console.workspaceTimezonePrompt.bothTitle`, `.bothConfirm`, `.bothSaved` | none: the checklist confirms each row on its own | + +Typing a country's name into a country picker now saves its code: 「中国」 typed into 默认国家, whose option reads 「中国 (CN)」, saves `CN` instead of being refused by the server's two-letter format check. Only a name exactly one option carries counts; any other text is sent as typed, as before. When the server does refuse a value for its format (`invalid_format`), the Settings page and the checklist now say so in the reader's language, naming the setting by its label: "Choose a value for Default country from the list." for a picker, and "Gateway model is not in a valid format. Use provider/model." (with the field's own help line) for a typed field. Every other refusal keeps the server's sentence. diff --git a/docs/qa/platform-checklist/RUNNER.md b/docs/qa/platform-checklist/RUNNER.md index c38c324e7f2..7b6d9e93e6d 100644 --- a/docs/qa/platform-checklist/RUNNER.md +++ b/docs/qa/platform-checklist/RUNNER.md @@ -204,17 +204,18 @@ contradicts it, and correct it here when it does. read-only" assumption: the pair is the door, and the read-only-package lock the console renders is a *different* gate (see `access-security.readonly-package-locks-studio`). -- **A fresh organization's first page opens under a one-time prompt: "Set the default - currency", or the system timezone, or both in one dialog.** An administrator is asked - once while `localization.timezone` is still the manifest default or the organization - has no default currency (`ui/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx`, - `workspaceTimezonePromptLogic.ts`), so on a fresh DB the dialog - (`data-testid="workspace-timezone-prompt"`) sits over the first page the admin opens, - Studio and app pages included (QA runs #29890 and #29893). Dismiss it with **Not now** - (Keep the default when only the timezone is asked), `data-testid="workspace-timezone-prompt-decline"`, - before any screenshot or DOM read. The answer is remembered per organization and user in - that browser's `localStorage` (`os:workspace-timezone-prompt::`), so a new - browser context asks again. ⛔ Do not score a clause FAIL for chrome hidden under it. +- **A fresh organization's Home carries a first-run checklist for the administrator; no + dialog opens over any page.** Until objectstack#30206 a one-time "Set the default + currency" / system timezone dialog sat over the first page an administrator opened. It + is gone: the same questions are rows of a card at the top of `/home` + (`ui/apps/console/src/pages/settings/FirstRunChecklist.tsx`, + `data-testid="first-run-checklist"`), shown only to a session that may write + `localization`, and it writes nothing until a row's own Confirm. Studio, Setup and app + pages open with nothing in front of them. A screenshot of Home on a fresh DB shows the + card above the page's sections; its close button + (`data-testid="first-run-checklist-dismiss"`) hides it for that organization and user in + that browser's `localStorage` (`os:first-run-checklist::`). ⛔ Do not score a + Home clause FAIL because the card pushes the page's sections down. - **`objectstack verify --rls` is a separate invocation — bare `verify` prints no RLS section at all.** `runRlsProofs` runs only behind the flag diff --git a/examples/app-showcase/e2e/global-setup.ts b/examples/app-showcase/e2e/global-setup.ts index aefe6da26b6..8080dfd43d7 100644 --- a/examples/app-showcase/e2e/global-setup.ts +++ b/examples/app-showcase/e2e/global-setup.ts @@ -1,4 +1,4 @@ -import { chromium, request, type FullConfig, type LaunchOptions } from '@playwright/test'; +import { request } from '@playwright/test'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; @@ -19,9 +19,8 @@ const API = process.env.SMOKE_API_URL || 'http://localhost:3000'; const EMAIL = process.env.SMOKE_EMAIL || 'admin@objectos.ai'; const PASSWORD = process.env.SMOKE_PASSWORD || 'admin123'; const STATE_PATH = 'e2e/.auth/state.json'; -const APP = process.env.SHOWCASE_APP || 'com.example.showcase'; -export default async function globalSetup(config: FullConfig) { +export default async function globalSetup() { const ctx = await request.newContext(); const res = await ctx.post(`${API}/api/v1/auth/sign-in/email`, { data: { email: EMAIL, password: PASSWORD }, @@ -38,102 +37,5 @@ export default async function globalSetup(config: FullConfig) { }; mkdirSync(dirname(STATE_PATH), { recursive: true }); writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); - - // The specs' own launch options (playwright.config.ts carries - // `OS_TEST_CHROMIUM_EXECUTABLE_PATH` there), so the prompt is answered in the - // browser the specs run in. - const promptRecord = await declineWorkspacePrompt(token, config.projects[0]?.use?.launchOptions); - if (promptRecord.length > 0) { - state.origins[0].localStorage.push(...promptRecord); - writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); - } } -/** - * Decline the console's one-time workspace prompt before any spec runs. - * - * The console asks an administrator, in ONE modal dialog on the first app page - * it renders, to set the workspace timezone while it is still the manifest - * default, and — since the prompt's currency arm (objectstack#28872, commit - * 2ac698baf1) — the default currency while `localization.currency` has no - * value. The showcase's dev seed derives the currency from the host's POSIX - * locale and leaves it unset on a host that sets none or names no country, and - * the seeded admin may write settings, so every spec would meet that dialog in - * front of the page it reads: the modal makes the page behind it - * `aria-hidden`, so role queries (`getByRole('checkbox')`, `getByRole('table')`, - * a section's disclosure button) find nothing and a click never lands. - * - * The run answers it ONCE, in a real browser, through the prompt's own decline - * button ("Not now" / "Keep the default"), the same way the console's own live - * suite does (ui/e2e/live/global-setup.ts). Declining writes nothing to the - * workspace — the showcase stays exactly as seeded, so no spec's reading of an - * amount moves — and the prompt keeps its per-device record of having asked in - * this origin's localStorage (`PROMPT_RECORD_PREFIX`:ORG:USER). That record, - * and nothing else the console stored while it was open, is what this returns - * for the storageState every spec reuses: the rest (the sidebar's wide-open - * flag, recent items, the active organization) would change the fresh-context - * layout the specs measure from — detail-shapes derives its 780 px window from - * a context with no stored sidebar state. - * - * Skipped when the backend says the prompt would not ask (the console's own - * condition per key, `workspaceTimezonePromptLogic.ts`). A prompt that was - * expected but never shown is reported, not failed: the specs then show whether - * anything stands in their way. A prompt that was declined but left no record - * throws: every spec would meet it again, and that is the run's one cause. - */ -const PROMPT_RECORD_PREFIX = 'os:workspace-timezone-prompt:'; - -async function declineWorkspacePrompt( - token: string, - launchOptions: LaunchOptions | undefined, -): Promise<{ name: string; value: string }[]> { - const api = await request.newContext({ extraHTTPHeaders: { Authorization: `Bearer ${token}` } }); - let wouldAsk = false; - try { - const res = await api.get(`${API}/api/settings/localization`); - if (res.ok()) { - const body = await res.json(); - const values = (body?.data ?? body)?.values; - const timezone = values?.timezone; - const currency = values?.currency; - const timezoneUnset = timezone?.source === 'default' && !timezone?.locked; - const currencyUnset = - currency?.source === 'default' && - !currency?.locked && - (currency?.value === null || currency?.value === undefined || currency?.value === ''); - wouldAsk = timezoneUnset || currencyUnset; - } - } finally { - await api.dispose(); - } - if (!wouldAsk) return []; - - const browser = await chromium.launch(launchOptions); - try { - const context = await browser.newContext({ storageState: STATE_PATH, baseURL: API }); - const page = await context.newPage(); - await page.goto(`/_console/apps/${APP}`, { waitUntil: 'domcontentloaded' }); - const prompt = page.getByTestId('workspace-timezone-prompt'); - try { - await prompt.waitFor({ state: 'visible', timeout: 60_000 }); - } catch { - console.warn('[showcase-smoke] the workspace timezone or currency is unset, but the console showed no prompt'); - return []; - } - await page.getByTestId('workspace-timezone-prompt-decline').click(); - await prompt.waitFor({ state: 'hidden' }); - const stored = await context.storageState(); - const record = (stored.origins.find((o) => o.origin === new URL(API).origin)?.localStorage ?? []).filter((e) => - e.name.startsWith(PROMPT_RECORD_PREFIX), - ); - if (record.length === 0) { - throw new Error( - `the workspace prompt was declined but left no ${PROMPT_RECORD_PREFIX}* record in localStorage, ` + - 'so every spec would meet it again', - ); - } - return record; - } finally { - await browser.close(); - } -} diff --git a/ui/apps/console/src/App.tsx b/ui/apps/console/src/App.tsx index f1efc08a19b..4eb226312b4 100644 --- a/ui/apps/console/src/App.tsx +++ b/ui/apps/console/src/App.tsx @@ -42,6 +42,7 @@ import { } from '@object-ui/app-shell'; import { AppContent, ConsolePermissionsRequest, ConsolePermissionsScope } from './AppContent'; +import { FirstRunChecklist } from './pages/settings/FirstRunChecklist'; import { publishAuthState } from './i18nSession'; import { FaviconSync } from './components/FaviconSync'; import { RootLandingRedirect } from './components/RootLandingRedirect'; @@ -195,11 +196,18 @@ function NativeShellRouting() { return null; } -/** Wraps `DefaultHomeLayout` so the FAB gets the signed-in user id. */ +/** + * Wraps `DefaultHomeLayout` so the FAB gets the signed-in user id, and draws + * an administrator's first-run checklist above the page's own sections + * (objectstack#30206). The route sits under `ConsolePermissionsScope`, which + * is what tells the checklist whether this session may write the settings it + * lists; for anyone else it draws nothing. + */ function HomeRoute() { const { user } = useAuth(); return ( + ); diff --git a/ui/apps/console/src/AppContent.tsx b/ui/apps/console/src/AppContent.tsx index 0cfce65e14e..16a61d4011d 100644 --- a/ui/apps/console/src/AppContent.tsx +++ b/ui/apps/console/src/AppContent.tsx @@ -18,7 +18,6 @@ import { createAuthenticatedFetch, useAuth } from '@object-ui/auth'; import type { DataSource } from '@object-ui/types'; import { LocalizationFetchProvider } from './LocalizationFetchProvider'; import { InAppFormRoute } from './components/InternalFormRoute'; -import { WorkspaceTimezonePrompt } from './pages/settings/WorkspaceTimezonePrompt'; import { APPROVAL_ACTION_OBJECT, APPROVAL_REQUEST_OBJECT, routeApprovalRecordReads } from './services/approvalRequestsDataSource'; const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage }))); @@ -528,11 +527,6 @@ export function AppContent() { /> - {/* objectui#11758 — asks an administrator once to set a still-default - workspace timezone. Under `ConsolePermissionsScope`, because that - provider is what answers whether this session may write the - setting; mounted once for the whole app shell, beside the routes. */} - ); } diff --git a/ui/apps/console/src/__tests__/App.homePermissions-12700.test.tsx b/ui/apps/console/src/__tests__/App.homePermissions-12700.test.tsx index bd31b1c87db..2d2c092dde0 100644 --- a/ui/apps/console/src/__tests__/App.homePermissions-12700.test.tsx +++ b/ui/apps/console/src/__tests__/App.homePermissions-12700.test.tsx @@ -178,7 +178,7 @@ vi.mock('../../../../packages/app-shell/src/hooks/useAiSurface', async (importOr vi.mock('../LocalizationFetchProvider', () => ({ LocalizationFetchProvider: ({ children }: { children?: ReactNode }) => <>{children}, })); -vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({ WorkspaceTimezonePrompt: () => null })); +vi.mock('../pages/settings/FirstRunChecklist', () => ({ FirstRunChecklist: () => null })); vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: () => null })); vi.mock('../components/SetupRoute', () => ({ SetupRoute: () => null })); vi.mock('../components/FormPage', () => ({ FormPage: () => null })); diff --git a/ui/apps/console/src/__tests__/App.permissionsWave-29114.test.tsx b/ui/apps/console/src/__tests__/App.permissionsWave-29114.test.tsx index 9df74891702..1ac90759305 100644 --- a/ui/apps/console/src/__tests__/App.permissionsWave-29114.test.tsx +++ b/ui/apps/console/src/__tests__/App.permissionsWave-29114.test.tsx @@ -145,7 +145,7 @@ vi.mock('@object-ui/app-shell', async (importOriginal) => { vi.mock('../LocalizationFetchProvider', () => ({ LocalizationFetchProvider: ({ children }: { children?: ReactNode }) => <>{children}, })); -vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({ WorkspaceTimezonePrompt: () => null })); +vi.mock('../pages/settings/FirstRunChecklist', () => ({ FirstRunChecklist: () => null })); vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: () => null })); vi.mock('../components/SetupRoute', () => ({ SetupRoute: () => null })); vi.mock('../components/FormPage', () => ({ FormPage: () => null })); diff --git a/ui/apps/console/src/__tests__/AppContent.approvalRecordReads.test.tsx b/ui/apps/console/src/__tests__/AppContent.approvalRecordReads.test.tsx index bb55f231381..55207958084 100644 --- a/ui/apps/console/src/__tests__/AppContent.approvalRecordReads.test.tsx +++ b/ui/apps/console/src/__tests__/AppContent.approvalRecordReads.test.tsx @@ -36,9 +36,6 @@ vi.mock('@object-ui/permissions', async (importOriginal) => ({ vi.mock('../LocalizationFetchProvider', () => ({ LocalizationFetchProvider: ({ children }: { children: ReactNode }) => <>{children}, })); -vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({ - WorkspaceTimezonePrompt: () => null, -})); import { AppContent } from '../AppContent'; diff --git a/ui/apps/console/src/components/FormPage.routePermissions-29187.test.tsx b/ui/apps/console/src/components/FormPage.routePermissions-29187.test.tsx index d40ecb35f6b..ae7820976d3 100644 --- a/ui/apps/console/src/components/FormPage.routePermissions-29187.test.tsx +++ b/ui/apps/console/src/components/FormPage.routePermissions-29187.test.tsx @@ -137,7 +137,7 @@ vi.mock('../../../../packages/app-shell/src/hooks/useAiSurface', async (importOr vi.mock('../LocalizationFetchProvider', () => ({ LocalizationFetchProvider: ({ children }: { children?: ReactNode }) => <>{children}, })); -vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({ WorkspaceTimezonePrompt: () => null })); +vi.mock('../pages/settings/FirstRunChecklist', () => ({ FirstRunChecklist: () => null })); vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: () => null })); vi.mock('../components/SetupRoute', () => ({ SetupRoute: () => null })); vi.mock('../components/FormPage', () => ({ FormPage: () => })); diff --git a/ui/apps/console/src/loadLocales.ts b/ui/apps/console/src/loadLocales.ts index 863b0160277..0a5ff914d65 100644 --- a/ui/apps/console/src/loadLocales.ts +++ b/ui/apps/console/src/loadLocales.ts @@ -40,9 +40,9 @@ let bootPainted: Promise | null = null; * page load, so every reader that waits on it is released by the same paint. * * Shared by the console's two readers whose answer no first-paint surface - * needs, this one and `WorkspaceTimezonePrompt`'s settings read. It waits on - * the splash's DOM marker rather than on any one component, because both are - * mounted beside the routes, under the splash. A tab opened in the background + * needs, this one and `FirstRunChecklist`'s settings read. It waits on the + * splash's DOM marker rather than on any one component, because neither is + * the surface the splash waits for. A tab opened in the background * paints no frame, so it resolves when the tab is first shown. */ export function whenBootPainted(): Promise { diff --git a/ui/apps/console/src/pages/auth/SetupPage.tsx b/ui/apps/console/src/pages/auth/SetupPage.tsx index 6f7baa8f921..d1230e33cb8 100644 --- a/ui/apps/console/src/pages/auth/SetupPage.tsx +++ b/ui/apps/console/src/pages/auth/SetupPage.tsx @@ -32,7 +32,7 @@ import { Label, } from '@object-ui/components'; import { withConsoleBase } from '../../utils/consoleBase'; -import { browserTimeZone } from '../settings/workspaceTimezonePromptLogic'; +import { browserTimeZone } from '../settings/firstRunChecklistLogic'; const AUTH_BASE = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; diff --git a/ui/apps/console/src/pages/settings/FirstRunChecklist.tsx b/ui/apps/console/src/pages/settings/FirstRunChecklist.tsx new file mode 100644 index 00000000000..7069a8b187e --- /dev/null +++ b/ui/apps/console/src/pages/settings/FirstRunChecklist.tsx @@ -0,0 +1,591 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * — what an administrator sets up first, at the top of Home + * (objectstack#30206). It replaces the dialog that asked for the workspace + * timezone and default currency on the first screens (objectui#11758, + * objectstack#28872), which opened before anything needed a currency and once + * covered the toast that said how to share an invitation (objectstack#30125 + * row 3). + * + * ## Where it is drawn + * + * Home (`/home`), above the page's own sections. Home is the environment's own + * home (ObjectUI ADR-0075): every deployment that declares no landing app opens + * there after sign-in, the Setup-only one included (`RootLandingRedirect`). + * Mounted by the console's Home route (`App.tsx`), inside the + * `ConsolePermissionsScope` that route already sits under: that provider is + * what answers which capabilities this session holds. + * + * ## Who sees it, and what each row says + * + * An administrator: a session that may write `localization` + * (`readChecklistSettings`). A member never sees it, and neither does a + * session whose capability answer has not arrived. Each row's done state is + * read from the real setting (`firstRunChecklistLogic.ts`), never from a click: + * + * 1. Organization name: the organization no longer carries the name the + * platform gave it; links to Setup → Organization. + * 2. Language and country, 3. timezone, 4. default currency: the + * localization keys, edited in place with the Settings page's own fields, + * pre-filled from the browser and written only on the row's Confirm. + * 5. Email delivery: mail can deliver; links to Setup → Mail Delivery. + * Only for a session that may write mail. + * 6. Invite colleagues: a second member or a pending invitation; links to + * Setup → Users, where Invite User is. + * + * A row whose state cannot be read is left out rather than shown as not done. + * Once every row shown is done the checklist draws nothing; it comes back on + * its own if a setting is unset again. + * + * ## Hiding it + * + * The close button hides it for this administrator in this workspace on this + * device (`dismissRecordKey`). Nothing is written to the server and no row + * changes: the deployment works the same with every row left as it is. + * + * ## The writes + * + * A row's Confirm is `saveSettingsNamespace('localization', changes)`, the + * function `SettingsView` saves through: one `PUT /api/settings/localization`, + * judged by the server against the manifest's `writePermission` and audited + * like any other settings save. Its refusals land where the Settings page puts + * them: a lock names the key, a validation refusal goes to the field's error + * slot (in the console's words for a format miss, `settingsFieldRefusals`), + * anything else carries the server's sentence. Cancel writes nothing. + * + * Its reads wait for the console's first paint (`whenBootPainted`, + * objectui#12980), so they never go out beside the page's own first reads. + */ + +import { useEffect, useState, type ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { toast } from 'sonner'; +import { CheckCircle2, Circle, Loader2, X } from 'lucide-react'; +import { Button, cn } from '@object-ui/components'; +import { useMetadata, resolveSetupAppPath } from '@object-ui/app-shell'; +import { useAuth } from '@object-ui/auth'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { usePermissions } from '@object-ui/permissions'; +import { SettingsField } from './SettingsField'; +import { getSettingsNamespace, lockedKeyOf, saveSettingsNamespace } from './api'; +import { fieldWording, settingsFieldRefusals } from './settingsFieldRefusals'; +import { resolveLabel, type ResolvedSettingValue } from './types'; +import { useSettingsLabel, type SettingsLabelHelpers } from './useSettingsLabel'; +import { whenBootPainted } from '../../loadLocales'; +import { + CURRENCY_KEY, + LOCALIZATION_NAMESPACE, + TIMEZONE_KEY, + deviceStorage, + dismissRecordKey, + hasInvitedColleagues, + isOrganizationNamed, + localizationRows, + markDismissed, + readBrowserAnswers, + readChecklistSettings, + wasDismissed, + type ChecklistField, + type ChecklistSettingsRow, + type ChecklistSettingsState, +} from './firstRunChecklistLogic'; + +type Translate = (key: string, options?: Record) => string; + +/** A currency's name in `language`, or `undefined` where `Intl.DisplayNames` cannot answer. */ +function currencyName(language: string, code: string): string | undefined { + try { + const name = new Intl.DisplayNames([language], { type: 'currency' }).of(code); + return name && name !== code ? name : undefined; + } catch { + return undefined; + } +} + +/** + * A field's value as the row names it: the currency by its name and code, any + * other key by its option's translated label, else the value itself. + */ +function describeValue(field: ChecklistField, value: string, labels: SettingsLabelHelpers, language: string): string { + if (field.key === CURRENCY_KEY) { + const name = currencyName(language, value); + return name ? `${name} (${value})` : value; + } + const option = (field.spec.options ?? []).find((o) => String(o.value) === value); + return option ? labels.optionLabel(field.key, value, resolveLabel(option.label)) : value; +} + +/** The current value of a field, as a string, or `''` when it holds none. */ +function currentValue(resolved: ResolvedSettingValue): string { + const value = resolved.value; + return value === null || value === undefined ? '' : String(value); +} + +/** One row of the list: its mark, its title and line, its action, and an optional editor below. */ +function ChecklistRow({ + id, + done, + title, + detail, + action, + children, +}: { + id: string; + done: boolean; + title: string; + detail?: string; + action?: ReactNode; + children?: ReactNode; +}) { + return ( +
  • + {done ? ( + + ) : ( + + )} +
    +

    {title}

    + {detail ?

    {detail}

    : null} + {children} +
    + {action ?
    {action}
    : null} +
  • + ); +} + +/** A row's action that opens a page. */ +function RowLink({ to, label, testId }: { to: string; label: string; testId: string }) { + return ( + + ); +} + +/** A localization row's title. */ +function settingsRowTitle(row: ChecklistSettingsRow, t: Translate): string { + if (row.id === 'language_country') return t('console.firstRunChecklist.languageCountry.title'); + if (row.id === 'timezone') return t('console.firstRunChecklist.timezone.title'); + return t('console.firstRunChecklist.currency.title'); +} + +/** The line under a localization row's title. */ +function settingsRowDetail( + row: ChecklistSettingsRow, + labels: SettingsLabelHelpers, + language: string, + t: Translate, +): string { + // A row that is done shows its value alone; two values are joined by the + // pack's own list separator, the one the Settings page's toasts use. + const named = (field: ChecklistField) => describeValue(field, currentValue(field.resolved), labels, language); + if (row.done) return row.fields.map(named).join(t('validation.formInvalidJoiner')); + if (row.id === 'language_country') { + const [locale, country] = row.fields; + return t('console.firstRunChecklist.languageCountry.todo', { language: named(locale), country: named(country) }); + } + if (row.id === 'timezone') return t('console.firstRunChecklist.timezone.todo', { current: named(row.fields[0]) }); + return t('console.firstRunChecklist.currency.todo'); +} + +/** The toast a localization row's Confirm raises once the server took the write. */ +function savedMessage( + row: ChecklistSettingsRow, + changes: Record, + labels: SettingsLabelHelpers, + language: string, + t: Translate, +): string { + if (row.id === 'timezone') return t('console.firstRunChecklist.timezone.saved', { zone: changes[TIMEZONE_KEY] }); + if (row.id === 'currency') { + const field = row.fields[0]; + return t('console.firstRunChecklist.currency.saved', { + currency: describeValue(field, changes[CURRENCY_KEY], labels, language), + }); + } + return t('console.firstRunChecklist.languageCountry.saved'); +} + +type FieldErrors = Record; + +/** + * A localization row's editor: the Settings page's own field for each key, + * pre-filled with the current value of a set key or the browser's offer for an + * unset one, and Confirm / Cancel. + */ +function SettingsRowEditor({ + row, + labels, + onSaved, + onCancel, +}: { + row: ChecklistSettingsRow; + labels: SettingsLabelHelpers; + onSaved: (values: Record) => void; + onCancel: () => void; +}) { + const { t, language } = useObjectTranslation(); + const [drafts, setDrafts] = useState>(() => + Object.fromEntries( + row.fields.map((field) => [field.key, field.set ? currentValue(field.resolved) : field.offer ?? '']), + ), + ); + const [saving, setSaving] = useState(false); + const [fieldErrors, setFieldErrors] = useState({}); + + // The keys Confirm writes: a field holding a value that is not already the + // key's own. A cleared field is left as it is. + const changes: Record = {}; + for (const field of row.fields) { + const draft = drafts[field.key] ?? ''; + if (draft === '') continue; + if (field.set && draft === currentValue(field.resolved)) continue; + changes[field.key] = draft; + } + const hasChanges = Object.keys(changes).length > 0; + const specifiers = row.fields.map((field) => field.spec); + + const onConfirm = async () => { + if (!hasChanges || saving) return; + setSaving(true); + setFieldErrors({}); + try { + const res = await saveSettingsNamespace(LOCALIZATION_NAMESPACE, changes); + toast.success(savedMessage(row, changes, labels, language, t)); + onSaved(res.values); + } catch (err) { + // The Settings page's own reading of a refused save, for the keys this + // row writes. `api.ts` parks the raw error body on `err.payload`. + const failure = err as { message?: string; payload?: { error?: { code?: unknown } } } | undefined; + const apiError = failure?.payload?.error; + if (apiError?.code === 'SETTINGS_LOCKED') { + const key = lockedKeyOf(apiError); + toast.error( + key ? t('console.settingsView.lockedByEnv', { key }) : t('console.settingsView.lockedByEnvNoKey'), + ); + } else { + const refused: FieldErrors = {}; + const perField = settingsFieldRefusals(apiError, specifiers, fieldWording(labels), t); + for (const f of perField ?? []) { + if (row.fields.some((field) => field.key === f.field)) refused[f.field] = f.message; + } + setFieldErrors(refused); + toast.error( + Object.keys(refused).length > 0 + ? t('console.settingsView.fieldErrorsToast', { + count: Object.keys(refused).length, + settings: row.fields + .filter((field) => refused[field.key]) + .map((field) => labels.fieldLabel(field.key, resolveLabel(field.spec.label))) + .join(t('validation.formInvalidJoiner')), + }) + : failure?.message ?? t('console.settingsView.saveFailed'), + ); + } + } finally { + setSaving(false); + } + }; + + return ( +
    + {row.fields.map((field) => ( + { + setDrafts((prev) => ({ ...prev, [field.key]: next == null ? '' : String(next) })); + setFieldErrors((prev) => ({ ...prev, [field.key]: undefined })); + }} + saving={saving} + locked={field.resolved.locked} + labels={labels} + error={fieldErrors[field.key]} + /> + ))} +

    {t('console.firstRunChecklist.prefilled')}

    +
    + + +
    +
    + ); +} + +export function FirstRunChecklist() { + const { t, language } = useObjectTranslation(); + const { user, activeOrganization, getMembers, listInvitations } = useAuth(); + const { systemPermissions } = usePermissions(); + const { apps } = useMetadata(); + const labels = useSettingsLabel(LOCALIZATION_NAMESPACE); + + const [editing, setEditing] = useState(null); + // The browser's answers are read once per mount: what the rows offer does not + // change under the administrator while they look at it. + const [browser] = useState(readBrowserAnswers); + + const userId = user?.id; + const orgId = activeOrganization?.id; + // Keyed on the capability LIST's content, never on the array's identity + // (AGENTS.md #10): a provider refetch that answers the same set must not + // re-run the reads. `null` keeps "not reported" apart from "holds nothing". + const heldKey = Array.isArray(systemPermissions) ? systemPermissions.join('\n') : null; + // Who is reading, where, holding what. Every answer below is stored with the + // identity it was read for, so an answer read for another one is never drawn. + const identity = userId && orgId && heldKey !== null ? [orgId, userId, heldKey].join('\u0000') : null; + const recordKey = userId && orgId ? dismissRecordKey(orgId, userId) : null; + + const [loaded, setLoaded] = useState<{ identity: string; settings: ChecklistSettingsState } | null>(null); + const [invited, setInvited] = useState<{ identity: string; colleagues: boolean } | null>(null); + // Hidden in this mount by the close button; a record from an earlier visit is + // read from the device below. + const [hiddenKey, setHiddenKey] = useState(null); + + useEffect(() => { + if (!identity || !userId || !orgId || heldKey === null) return; + if (wasDismissed(deviceStorage(), dismissRecordKey(orgId, userId))) return; + const held = heldKey === '' ? [] : heldKey.split('\n'); + + let cancelled = false; + whenBootPainted() + .then(() => (cancelled ? null : readChecklistSettings(held))) + .then((found) => { + if (cancelled || !found) return; + setLoaded({ identity, settings: found }); + // Only an administrator's checklist asks who else is here. + Promise.all([getMembers(orgId), listInvitations(orgId)]) + .then(([members, invitations]) => { + if (!cancelled) setInvited({ identity, colleagues: hasInvitedColleagues(members, invitations) }); + }) + .catch(() => { + // Members or invitations unreadable: the invite row is left out. + }); + }) + .catch(() => { + // A settings service that is absent or failing shows no checklist. + }); + return () => { + cancelled = true; + }; + // `getMembers` / `listInvitations` are the provider's stable callbacks; + // the reads re-run on who is signed in, where, and what they may do, all + // of which `identity` carries. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [identity]); + + const settings = loaded && loaded.identity === identity ? loaded.settings : null; + const colleagues = invited && invited.identity === identity ? invited.colleagues : undefined; + const dismissed = + recordKey !== null && (hiddenKey === recordKey || wasDismissed(deviceStorage(), recordKey)); + const setSettings = (update: (prev: ChecklistSettingsState) => ChecklistSettingsState) => + setLoaded((prev) => (prev ? { identity: prev.identity, settings: update(prev.settings) } : prev)); + + if (!settings || dismissed || !orgId || !userId || !recordKey) return null; + + const setupPath = resolveSetupAppPath(apps); + const localization = localizationRows(settings.localization.manifest, settings.localization.values, browser); + const organizationNamed = isOrganizationNamed(activeOrganization?.name); + + const rows: Array<{ id: string; done: boolean; node: ReactNode }> = []; + + rows.push({ + id: 'organization_name', + done: organizationNamed, + node: ( + + } + /> + ), + }); + + for (const row of localization) { + const open = editing === row.id; + rows.push({ + id: row.id, + done: row.done, + node: ( + + ) : open ? null : ( + + ) + } + > + {open && !row.done ? ( + setEditing(null)} + onSaved={(values) => { + setEditing(null); + setSettings((prev) => ({ + ...prev, + localization: { ...prev.localization, values: { ...prev.localization.values, ...values } }, + })); + // The write's read-back is merged at once; the namespace is + // then read again, as the Settings page does after a save, so + // every row shows what the server now resolves. + getSettingsNamespace(LOCALIZATION_NAMESPACE) + .then((fresh) => { + setSettings((prev) => ({ ...prev, localization: { manifest: fresh.manifest, values: fresh.values } })); + }) + .catch(() => { + // Keep what is on screen; the next visit reads again. + }); + }} + /> + ) : null} + + ), + }); + } + + if (settings.mail) { + const done = settings.mail.done; + rows.push({ + id: 'mail', + done, + node: ( + + } + /> + ), + }); + } + + if (colleagues !== undefined) { + rows.push({ + id: 'invite', + done: colleagues, + node: ( + + } + /> + ), + }); + } + + const doneCount = rows.filter((row) => row.done).length; + if (rows.length === 0 || doneCount === rows.length) return null; + + const hide = () => { + markDismissed(deviceStorage(), recordKey); + setHiddenKey(recordKey); + }; + + return ( +
    +
    +
    +
    +

    + {t('console.firstRunChecklist.title')} +

    +

    {t('console.firstRunChecklist.description')}

    +
    +
    + + {t('console.firstRunChecklist.progress', { done: doneCount, total: rows.length })} + + +
    +
    +
      {rows.map((row) => row.node)}
    +
    +
    + ); +} diff --git a/ui/apps/console/src/pages/settings/SettingsField.tsx b/ui/apps/console/src/pages/settings/SettingsField.tsx index 0b2e4abefbf..175cd9029a6 100644 --- a/ui/apps/console/src/pages/settings/SettingsField.tsx +++ b/ui/apps/console/src/pages/settings/SettingsField.tsx @@ -58,8 +58,8 @@ import type { SettingsLabelHelpers } from './useSettingsLabel'; * of text (an installed package's own words) through the same unconditional * `rehype-sanitize` pass, used as it is. * - * Lazy on purpose. This module is in the EAGER closure (`AppContent` imports - * `WorkspaceTimezonePrompt`, which imports it), and a static import would put + * Lazy on purpose. This module is in the EAGER closure (`App` imports + * `FirstRunChecklist`, which imports it), and a static import would put * `plugin-markdown`'s whole chunk, its syntax highlighter included, on every * console page load. Until the chunk arrives the body shows as its own plain * text. @@ -106,8 +106,9 @@ export interface SettingsFieldProps { labels?: SettingsLabelHelpers; /** * Server-side rejection for THIS field, from the last failed save - * (objectstack#4224). Already localized by the server — rendered verbatim, - * not re-worded here. + * (objectstack#4224). Rendered verbatim, not re-worded here: the host passes + * the server's sentence, or the console's own for a format miss + * (`settingsFieldRefusals`, objectstack#30206). * * Replaces the help text while present, rather than stacking below it: the * two occupy the same slot and say the same kind of thing, and a description @@ -124,7 +125,7 @@ export interface SettingsFieldProps { note?: string; /** * The scope this row's Save writes. The page passes it; a host that does - * not (the timezone prompt) keeps the badges a lower scope would see. + * not (the first-run checklist) keeps the badges a lower scope would see. */ writeScope?: SpecifierScope; } @@ -312,6 +313,35 @@ function FieldError({ id, message }: { id: string; message: string }) { ); } +/** + * An option's name: its label without the option's own code beside it. + * The country picker labels read `Name (CODE)` (`中国 (CN)`, `Germany (DE)`), + * the currency suggestions `CODE — Name` (`CNY — Chinese Yuan`); either way the + * code is the option's `value`, so only that code is taken off, with the + * brackets or the dash that join it. A label that carries no such code is its + * own name. + */ +function optionName(option: { value: string; label: string }): string { + const label = option.label.trim(); + const code = option.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const suffixed = new RegExp(`^(.*?)\\s*[((]\\s*${code}\\s*[))]$`, 'i').exec(label); + if (suffixed && suffixed[1].trim() !== '') return suffixed[1].trim(); + const prefixed = new RegExp(`^${code}\\s*[—–-]\\s*(.+)$`, 'i').exec(label); + if (prefixed && prefixed[1].trim() !== '') return prefixed[1].trim(); + return label; +} + +/** + * The one option whose name is `text`, compared trimmed and case-insensitively, + * or `undefined` when no option, or more than one, carries that name. + */ +function optionByName(options: readonly T[], text: string): T | undefined { + const wanted = text.trim().toLocaleLowerCase(); + if (wanted === '') return undefined; + const matches = options.filter((option) => optionName(option).toLocaleLowerCase() === wanted); + return matches.length === 1 ? matches[0] : undefined; +} + /** * — the control a `select` gets when its specifier declares a * `valueDomain` (objectstack#5712 / PR objectstack#6581). @@ -336,8 +366,14 @@ function FieldError({ id, message }: { id: string; message: string }) { * the box while `DE` is what is committed. A datalist commits its option's * `value` attribute into the box verbatim, which is why the suggestions carry * the label there and `committedValue` maps it back. Typed text that equals an - * option's `value` is a code and is committed as typed; any other text is - * committed verbatim, as before. + * option's `value` is a code and is committed as typed. + * + * Typed text that is an option's NAME is committed as that option's code + * (objectstack#30206): 「中国」 typed into 默认国家, whose option reads + * 「中国 (CN)」, commits `CN`. The name is the label without the option's own + * code beside it (`optionByName`), compared trimmed and case-insensitively, and + * only a name exactly one option carries counts. Any other text is committed + * verbatim, as before, and the server judges it. * * Props beyond its own are forwarded to the `` so `wrapper`'s * `aria-invalid` / `aria-describedby` reach the focusable control rather than a @@ -364,7 +400,7 @@ function DomainCombobox({ code === null ? '' : options.find((opt) => opt.value === code)?.label ?? code; const committedValue = (text: string): string | null => { if (text === '' || options.some((opt) => opt.value === text)) return textOrUnset(text); - return options.find((opt) => opt.label === text)?.value ?? text; + return options.find((opt) => opt.label === text)?.value ?? optionByName(options, text)?.value ?? text; }; // What the admin typed, kept while it still stands for the stored value; a // value changed from outside (a reset, a re-read after save) shows as its name. diff --git a/ui/apps/console/src/pages/settings/SettingsView.tsx b/ui/apps/console/src/pages/settings/SettingsView.tsx index b5ed73e960f..b6a1b51edef 100644 --- a/ui/apps/console/src/pages/settings/SettingsView.tsx +++ b/ui/apps/console/src/pages/settings/SettingsView.tsx @@ -14,9 +14,9 @@ import { ConsolePageHeader } from '@object-ui/app-shell'; import { Loader2, ArrowLeft, ChevronRight, RotateCcw, ShieldAlert } from 'lucide-react'; import { Button, Card, CardContent, Skeleton, Badge, getLazyIcon } from '@object-ui/components'; import { ExpressionEvaluator } from '@object-ui/core'; -import { extractFieldErrors } from '@object-ui/react'; import { useObjectTranslation } from '@object-ui/i18n'; import { SettingsField } from './SettingsField'; +import { fieldWording, settingsFieldRefusals } from './settingsFieldRefusals'; import { getJudgedClientAddress, getSettingsNamespace, @@ -569,7 +569,11 @@ export function SettingsView() { // repeats each row's whole message, help text included. // `{{settings}}` is the labels joined with the pack's own list // separator, the one the record form's field lists use. - const perField = extractFieldErrors(apiError); + // + // objectstack#30206 — a format miss (`invalid_format`) reads in the + // console's words and the reader's language; every other refusal + // keeps the server's sentence (`settingsFieldRefusals`). + const perField = settingsFieldRefusals(apiError, manifest.specifiers, fieldWording(labels), t); if (perField?.length) { setFieldErrors(Object.fromEntries(perField.map((f) => [f.field, f.message]))); const named = refusedSettingLabels(perField, manifest.specifiers, labels); diff --git a/ui/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx b/ui/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx deleted file mode 100644 index 1f13ad39ff0..00000000000 --- a/ui/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * — asks an administrator ONCE to set a workspace - * timezone that is still the manifest default, pre-filled with the browser's - * zone (objectui#11758, the gate the objectui#11693 ruling put on PR #11729), - * and a default currency that is still unset, pre-filled with the currency of - * the browser's region (objectstack#28872). When both are unset it is one - * dialog with both fields, and one write. - * - * Mounted once, in the console's app shell (`AppContent`), inside the - * `MePermissionsProvider` that shell already mounts: that provider is what - * answers which capabilities this session holds. When to ask, and what to - * offer, is decided in `workspaceTimezonePromptLogic.ts`; this file is the dialog. - * - * Both halves of the write are the Settings page's own: - * - * - each FIELD is `` rendering the manifest's own `timezone` or - * `currency` specifier, so the value is edited in the same control (the - * `valueDomain` combobox with the curated options as suggestions), with the - * same translated label and help, and a refusal lands in the same error slot; - * - the WRITE is `saveSettingsNamespace('localization', { timezone, currency })` - * carrying the keys whose field holds a value, the function `SettingsView` - * saves through: one `PUT /api/settings/localization`, judged by the server - * against the manifest's `writePermission` and audited like any other - * settings save. - * - * Decline writes nothing. Closing the dialog any other way (Escape, the - * overlay, the close button) is a decline. - * - * Its settings read waits for the console's first paint (`whenBootPainted`, - * objectui#12980): this component mounts beside the routes, under the boot - * splash, and a dialog has nothing to say before the page it sits over has - * painted — so the read no longer goes out beside the page's own reads. - */ - -import { useEffect, useState } from 'react'; -import { toast } from 'sonner'; -import { Loader2 } from 'lucide-react'; -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@object-ui/components'; -import { useAuth } from '@object-ui/auth'; -import { useObjectTranslation } from '@object-ui/i18n'; -import { usePermissions } from '@object-ui/permissions'; -import { extractFieldErrors } from '@object-ui/react'; -import { SettingsField } from './SettingsField'; -import { lockedKeyOf, saveSettingsNamespace } from './api'; -import { useSettingsLabel } from './useSettingsLabel'; -import { whenBootPainted } from '../../loadLocales'; -import { - CURRENCY_KEY, - LOCALIZATION_NAMESPACE, - TIMEZONE_KEY, - browserCurrency, - browserTimeZone, - deviceStorage, - findWorkspacePromptOffer, - markAsked, - promptRecordKey, - wasAsked, - type WorkspacePromptOffer, -} from './workspaceTimezonePromptLogic'; - -/** A currency's name in `language`, or `undefined` where `Intl.DisplayNames` cannot answer. */ -function currencyName(language: string, code: string): string | undefined { - try { - const name = new Intl.DisplayNames([language], { type: 'currency' }).of(code); - return name && name !== code ? name : undefined; - } catch { - return undefined; - } -} - -type FieldErrors = Partial>; - -export function WorkspaceTimezonePrompt() { - const { t, language } = useObjectTranslation(); - const { user, activeOrganization } = useAuth(); - const { systemPermissions } = usePermissions(); - const labels = useSettingsLabel(LOCALIZATION_NAMESPACE); - - const [offer, setOffer] = useState(null); - const [open, setOpen] = useState(false); - const [zoneDraft, setZoneDraft] = useState(''); - const [currencyDraft, setCurrencyDraft] = useState(''); - const [saving, setSaving] = useState(false); - const [fieldErrors, setFieldErrors] = useState({}); - - const userId = user?.id; - const orgId = activeOrganization?.id; - // Keyed on the capability LIST's content, never on the array's identity - // (AGENTS.md #10): a provider refetch that answers the same set must not - // re-run the decision. `null` keeps "not reported" apart from "holds nothing". - const heldKey = Array.isArray(systemPermissions) ? systemPermissions.join('\n') : null; - - useEffect(() => { - if (!userId || !orgId || heldKey === null) return; - const storage = deviceStorage(); - const recordKey = promptRecordKey(orgId, userId); - if (!storage || wasAsked(storage, recordKey)) return; - const browser = { zone: browserTimeZone(), currency: browserCurrency() }; - if (!browser.zone && !browser.currency) return; - const held = heldKey === '' ? [] : heldKey.split('\n'); - - let cancelled = false; - whenBootPainted() - .then(() => (cancelled ? null : findWorkspacePromptOffer(held, browser))) - .then((found) => { - if (cancelled || !found) return; - // Re-read: another tab may have shown it while this one was fetching. - if (wasAsked(storage, recordKey) || !markAsked(storage, recordKey)) return; - setOffer(found); - setZoneDraft(found.timezone?.zone ?? ''); - setCurrencyDraft(found.currency?.currency ?? ''); - setOpen(true); - }) - .catch(() => { - // A settings service that is absent or failing is not a reason to ask. - }); - return () => { - cancelled = true; - }; - }, [userId, orgId, heldKey]); - - if (!offer) return null; - - const { timezone, currency } = offer; - const both = Boolean(timezone && currency); - // The keys Confirm writes: an arm whose field was cleared is left unset. - const changes: Record = {}; - if (timezone && zoneDraft) changes[TIMEZONE_KEY] = zoneDraft; - if (currency && currencyDraft) changes[CURRENCY_KEY] = currencyDraft; - const hasChanges = Object.keys(changes).length > 0; - - // The offer as the copy names it: the currency's name in the UI language, and its code. - const offered = currency - ? { name: currencyName(language, currency.currency) ?? currency.currency, code: currency.currency } - : undefined; - - const title = both - ? t('console.workspaceTimezonePrompt.bothTitle') - : currency - ? t('console.workspaceTimezonePrompt.currencyTitle') - : t('console.workspaceTimezonePrompt.title'); - const confirmLabel = both - ? t('console.workspaceTimezonePrompt.bothConfirm') - : currency - ? t('console.workspaceTimezonePrompt.currencyConfirm') - : t('console.workspaceTimezonePrompt.confirm'); - // "Keep the default" names the timezone's manifest default; the currency has none to keep. - const declineLabel = currency - ? t('console.workspaceTimezonePrompt.notNow') - : t('console.workspaceTimezonePrompt.decline'); - - const savedMessage = () => { - const zone = changes[TIMEZONE_KEY]; - const code = changes[CURRENCY_KEY]; - if (zone && code) return t('console.workspaceTimezonePrompt.bothSaved', { zone, currency: code }); - if (code) return t('console.workspaceTimezonePrompt.currencySaved', { currency: code }); - return t('console.workspaceTimezonePrompt.saved', { zone }); - }; - - const onConfirm = async () => { - if (!hasChanges || saving) return; - setSaving(true); - setFieldErrors({}); - try { - await saveSettingsNamespace(LOCALIZATION_NAMESPACE, changes); - toast.success(savedMessage()); - setOpen(false); - } catch (err) { - // The Settings page's own reading of a refused save, for the keys this - // dialog writes: a lock names the key, a validation refusal lands in the - // field's error slot, anything else carries the server's sentence. - // `api.ts` parks the raw error body on `err.payload`. - const failure = err as { message?: string; payload?: { error?: { code?: unknown } } } | undefined; - const apiError = failure?.payload?.error; - if (apiError?.code === 'SETTINGS_LOCKED') { - const key = lockedKeyOf(apiError); - toast.error( - key - ? t('console.settingsView.lockedByEnv', { key }) - : t('console.settingsView.lockedByEnvNoKey'), - ); - } else { - const refused: FieldErrors = {}; - for (const f of extractFieldErrors(apiError) ?? []) { - if (f.field === TIMEZONE_KEY || f.field === CURRENCY_KEY) refused[f.field] = f.message; - } - setFieldErrors(refused); - toast.error(failure?.message ?? t('console.settingsView.saveFailed')); - } - } finally { - setSaving(false); - } - }; - - return ( - { - if (!next && !saving) setOpen(false); - }} - > - - - {title} - - {timezone - ? t('console.workspaceTimezonePrompt.description', { current: timezone.current }) - : t('console.workspaceTimezonePrompt.currencyDescription', offered)} - - - {timezone ? ( - { - setZoneDraft(next == null ? '' : String(next)); - setFieldErrors((prev) => ({ ...prev, [TIMEZONE_KEY]: undefined })); - }} - saving={saving} - labels={labels} - error={fieldErrors[TIMEZONE_KEY]} - /> - ) : null} - {both ? ( -

    - {t('console.workspaceTimezonePrompt.currencyDescription', offered)} -

    - ) : null} - {currency ? ( - { - setCurrencyDraft(next == null ? '' : String(next)); - setFieldErrors((prev) => ({ ...prev, [CURRENCY_KEY]: undefined })); - }} - saving={saving} - labels={labels} - error={fieldErrors[CURRENCY_KEY]} - /> - ) : null} -

    - {t('console.workspaceTimezonePrompt.laterHint')} -

    - - - - -
    -
    - ); -} diff --git a/ui/apps/console/src/pages/settings/__tests__/FirstRunChecklist.test.tsx b/ui/apps/console/src/pages/settings/__tests__/FirstRunChecklist.test.tsx new file mode 100644 index 00000000000..4774d50af00 --- /dev/null +++ b/ui/apps/console/src/pages/settings/__tests__/FirstRunChecklist.test.tsx @@ -0,0 +1,413 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The administrator's first-run checklist on Home (objectstack#30206), which + * replaces the dialog that asked for the workspace timezone and currency. + * + * The two rules the dialog's rulings set still hold, and are pinned here on the + * wire: the browser's answers only PRE-FILL a row, and nothing is written until + * the row's Confirm (objectui#11693 B2); an unset currency stays on the list + * until it is set (objectstack#28872). + * + * Every pin drives the REAL settings client (`api.ts`) against a stubbed + * `fetch`, so "exactly one write" counts `PUT /api/settings/localization` + * requests rather than calls on a mock, and the capability answer comes from + * the real `MePermissionsProvider`. The browser is stubbed at its readers: + * `navigator.languages` / `navigator.language` and + * `Intl.DateTimeFormat.prototype.resolvedOptions`. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { + MePermissionsProvider, + type MePermissionsAuthenticatedResponse, + type MePermissionsResponse, +} from '@object-ui/permissions'; + +const { AUTH, toastError, toastSuccess } = vi.hoisted(() => ({ + AUTH: { + user: { id: 'u_admin' }, + activeOrganization: { id: 'org_1', name: 'Default Organization' } as { id: string; name: string }, + getMembers: vi.fn(), + listInvitations: vi.fn(), + }, + toastError: vi.fn(), + toastSuccess: vi.fn(), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => AUTH, +})); +vi.mock('sonner', () => ({ toast: { error: toastError, success: toastSuccess } })); +// The checklist reads the apps (for the Setup app's path) from the metadata +// provider; the real surface is inherited and only that read is stubbed. +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ apps: [], objects: [], loading: false, error: null, refresh: async () => {} }), + resolveSetupAppPath: () => '/apps/setup', +})); + +import { FirstRunChecklist } from '../FirstRunChecklist'; +import { __resetBootPainted } from '../../../loadLocales'; +import type { Specifier } from '../types'; + +const LOCALE_SPEC: Specifier = { + type: 'select', + key: 'locale', + label: 'Default locale', + default: 'en-US', + options: [ + { value: 'en-US', label: 'English (US)' }, + { value: 'zh-CN', label: '中文(简体)' }, + ], +}; +const COUNTRY_SPEC: Specifier = { + type: 'select', + key: 'default_country', + label: 'Default country', + default: 'US', + pattern: '^[A-Za-z]{2}$', + valueDomain: 'iso_3166_alpha2', + options: [ + { value: 'US', label: 'United States (US)' }, + { value: 'CN', label: 'China (CN)' }, + ], +}; +const TIMEZONE_SPEC: Specifier = { + type: 'select', + key: 'timezone', + label: 'Default timezone', + default: 'UTC', + valueDomain: 'iana_time_zone', + options: [ + { value: 'UTC', label: 'UTC' }, + { value: 'Asia/Shanghai', label: '(UTC+08) Shanghai' }, + ], +}; +const CURRENCY_SPEC: Specifier = { + type: 'select', + key: 'currency', + label: 'Default currency', + valueDomain: 'iso_4217_currency', + options: [ + { value: 'USD', label: 'USD — US Dollar' }, + { value: 'CNY', label: 'CNY — Chinese Yuan' }, + ], +}; + +const LOCALIZATION_MANIFEST = { + namespace: 'localization', + version: 2, + label: 'Localization', + scope: 'tenant', + readPermission: 'setup.access', + writePermission: 'setup.write', + specifiers: [{ type: 'group', id: 'region', label: 'Region' }, LOCALE_SPEC, COUNTRY_SPEC, TIMEZONE_SPEC, CURRENCY_SPEC], +}; +const MAIL_MANIFEST = { + namespace: 'mail', + version: 1, + label: 'Mail Delivery', + scope: 'global', + readPermission: 'manage_platform_settings', + writePermission: 'manage_platform_settings', + specifiers: [ + { type: 'select', key: 'provider', label: 'Provider', default: 'smtp', options: [{ value: 'smtp', label: 'SMTP' }, { value: 'log', label: 'Log only' }] }, + { type: 'text', key: 'smtp_host', label: 'SMTP host' }, + { type: 'text', key: 'from_email', label: 'From address' }, + ], +}; + +type Source = 'env' | 'global' | 'tenant' | 'user' | 'default'; +function resolved(value: unknown, source: Source = 'default') { + return { value, source, locked: source === 'env', cascadeChain: [{ scope: source, value, effective: true }] }; +} + +function freshLocalization(): Record> { + return { + locale: resolved('en-US'), + default_country: resolved('US'), + timezone: resolved('UTC'), + currency: resolved(null), + }; +} + +let localizationValues = freshLocalization(); +let mailValues: Record> = { provider: resolved('smtp') }; +let putRefusal: unknown = null; + +function ok(data: unknown) { + return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data }) } as unknown as Response; +} + +const fetchMock = vi.fn(); +function routeFetch() { + fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + if (url === '/api/settings' && method === 'GET') return ok({ manifests: [LOCALIZATION_MANIFEST, MAIL_MANIFEST] }); + if (url === '/api/settings/localization' && method === 'GET') { + return ok({ manifest: LOCALIZATION_MANIFEST, values: localizationValues }); + } + if (url === '/api/settings/mail' && method === 'GET') return ok({ manifest: MAIL_MANIFEST, values: mailValues }); + if (url === '/api/settings/localization' && method === 'PUT') { + if (putRefusal) { + return { ok: false, status: 400, statusText: 'Bad Request', json: async () => putRefusal } as unknown as Response; + } + const body = JSON.parse(String(init?.body)) as Record; + const written = Object.fromEntries(Object.entries(body).map(([k, v]) => [k, resolved(v, 'tenant')])); + localizationValues = { ...localizationValues, ...written }; + return ok({ values: written }); + } + return { ok: false, status: 404, statusText: 'Not Found', json: async () => ({}) } as unknown as Response; + }); +} + +const calls = (method: string, url: string) => + fetchMock.mock.calls.filter(([u, init]) => u === url && ((init as RequestInit | undefined)?.method ?? 'GET') === method); +const puts = () => calls('PUT', '/api/settings/localization'); + +function permissionsPayload(systemPermissions: string[] | undefined): MePermissionsResponse { + const base: MePermissionsAuthenticatedResponse = { + authenticated: true, + userId: 'u_admin', + tenantId: 'org_1', + positions: [], + hasAnyQueue: false, + permissionSets: [], + objects: {}, + fields: {}, + }; + return systemPermissions === undefined ? base : { ...base, systemPermissions }; +} + +const ADMIN = ['setup.access', 'setup.write', 'manage_platform_settings']; +const MEMBER = ['setup.access']; + +/** No default for `systemPermissions`: "not reported" must stay apart from the admin set. */ +function renderChecklist(systemPermissions: string[] | undefined, language = 'en') { + return render( + + + + + + + , + ); +} + +async function settle() { + await act(async () => { + for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); + }); +} + +const row = (id: string) => screen.getByTestId(`first-run-checklist-row-${id}`); + +let browserZone = 'Asia/Shanghai'; +let browserLanguages = ['zh-CN']; +const realResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions; + +beforeEach(() => { + fetchMock.mockReset(); + toastError.mockReset(); + toastSuccess.mockReset(); + localStorage.clear(); + localizationValues = freshLocalization(); + mailValues = { provider: resolved('smtp') }; + putRefusal = null; + AUTH.activeOrganization = { id: 'org_1', name: 'Default Organization' }; + AUTH.getMembers.mockReset().mockResolvedValue([{ id: 'm_1' }]); + AUTH.listInvitations.mockReset().mockResolvedValue([]); + browserZone = 'Asia/Shanghai'; + browserLanguages = ['zh-CN']; + vi.stubGlobal('fetch', fetchMock); + routeFetch(); + vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (this: Intl.DateTimeFormat) { + return { ...realResolvedOptions.call(this), timeZone: browserZone }; + }); + vi.spyOn(window.navigator, 'languages', 'get').mockImplementation(() => browserLanguages); + vi.spyOn(window.navigator, 'language', 'get').mockImplementation(() => browserLanguages[0] ?? ''); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('only an administrator sees it', () => { + it('a member: the manifest list is read, no namespace, and nothing is drawn', async () => { + renderChecklist(MEMBER); + await settle(); + expect(calls('GET', '/api/settings')).toHaveLength(1); + expect(calls('GET', '/api/settings/localization')).toHaveLength(0); + expect(screen.queryByTestId('first-run-checklist')).toBeNull(); + expect(AUTH.getMembers).not.toHaveBeenCalled(); + }); + + it('no capability answer, or one holding nothing: no request at all', async () => { + renderChecklist(undefined); + await settle(); + cleanup(); + renderChecklist([]); + await settle(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId('first-run-checklist')).toBeNull(); + }); +}); + +describe('each row is read from the real setting', () => { + it('a fresh workspace: every row is shown not done, each with its way in', async () => { + renderChecklist(ADMIN); + expect(await screen.findByTestId('first-run-checklist')).toBeTruthy(); + expect(await screen.findByTestId('first-run-checklist-row-invite')).toBeTruthy(); + const ids = ['organization_name', 'language_country', 'timezone', 'currency', 'mail', 'invite']; + for (const id of ids) expect(row(id).getAttribute('data-done')).toBe('false'); + expect(screen.getByTestId('first-run-checklist-progress').textContent).toBe('0 of 6 done'); + expect(screen.getByText('Not set yet, so amounts are shown without a currency symbol.')).toBeTruthy(); + expect(screen.getByTestId('first-run-checklist-link-organization_name').getAttribute('href')).toBe( + '/apps/setup/sys_organization/record/org_1', + ); + expect(screen.getByTestId('first-run-checklist-link-mail').getAttribute('href')).toBe('/apps/setup/system/settings/mail'); + expect(screen.getByTestId('first-run-checklist-link-invite').getAttribute('href')).toBe('/apps/setup/sys_user/view/all_users'); + // Reading is all it did. + expect(puts()).toHaveLength(0); + }); + + it('rows the server reports as set are done, and a fully set workspace draws nothing', async () => { + localizationValues = { + locale: resolved('zh-CN', 'tenant'), + default_country: resolved('CN', 'tenant'), + timezone: resolved('Asia/Shanghai', 'tenant'), + currency: resolved('CNY', 'tenant'), + }; + renderChecklist(ADMIN); + await screen.findByTestId('first-run-checklist-row-invite'); + for (const id of ['language_country', 'timezone', 'currency']) expect(row(id).getAttribute('data-done')).toBe('true'); + expect(row('organization_name').getAttribute('data-done')).toBe('false'); + expect(screen.getByTestId('first-run-checklist-progress').textContent).toBe('3 of 6 done'); + cleanup(); + + AUTH.activeOrganization = { id: 'org_1', name: 'Acme' }; + AUTH.getMembers.mockResolvedValue([{ id: 'm_1' }, { id: 'm_2' }]); + mailValues = { provider: resolved('smtp', 'global'), smtp_host: resolved('mx', 'global'), from_email: resolved('a@b.co', 'global') }; + renderChecklist(ADMIN); + await settle(); + await waitFor(() => expect(AUTH.getMembers).toHaveBeenCalled()); + await settle(); + expect(screen.queryByTestId('first-run-checklist')).toBeNull(); + }); +}); + +describe('the browser pre-fills; only Confirm writes (objectui#11693 B2, objectstack#28872)', () => { + it('Set opens the editor pre-filled from the browser; Confirm sends exactly one PUT and the row is done', async () => { + renderChecklist(ADMIN); + fireEvent.click(await screen.findByTestId('first-run-checklist-set-currency')); + const editor = screen.getByTestId('first-run-checklist-editor-currency'); + expect((within(editor).getByRole('combobox') as HTMLInputElement).value).toBe('CNY — Chinese Yuan'); + await settle(); + expect(puts()).toHaveLength(0); + + fireEvent.click(screen.getByTestId('first-run-checklist-confirm-currency')); + await waitFor(() => expect(row('currency').getAttribute('data-done')).toBe('true')); + expect(puts()).toHaveLength(1); + expect(JSON.parse(String((puts()[0][1] as RequestInit).body))).toEqual({ currency: 'CNY' }); + expect(toastSuccess).toHaveBeenCalledTimes(1); + }); + + it('language and country are one offer from one browser language', async () => { + renderChecklist(ADMIN); + fireEvent.click(await screen.findByTestId('first-run-checklist-set-language_country')); + fireEvent.click(screen.getByTestId('first-run-checklist-confirm-language_country')); + await waitFor(() => expect(puts()).toHaveLength(1)); + expect(JSON.parse(String((puts()[0][1] as RequestInit).body))).toEqual({ locale: 'zh-CN', default_country: 'CN' }); + }); + + it('Cancel writes nothing; hiding the checklist writes nothing and keeps it hidden on this device', async () => { + const view = renderChecklist(ADMIN); + fireEvent.click(await screen.findByTestId('first-run-checklist-set-timezone')); + fireEvent.click(screen.getByTestId('first-run-checklist-cancel-timezone')); + expect(screen.queryByTestId('first-run-checklist-editor-timezone')).toBeNull(); + + fireEvent.click(screen.getByTestId('first-run-checklist-dismiss')); + expect(screen.queryByTestId('first-run-checklist')).toBeNull(); + await settle(); + expect(puts()).toHaveLength(0); + expect(localStorage.getItem('os:first-run-checklist:org_1:u_admin')).not.toBeNull(); + + view.unmount(); + fetchMock.mockClear(); + renderChecklist(ADMIN); + await settle(); + expect(screen.queryByTestId('first-run-checklist')).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('a refused write marks the field in the console words and leaves the row not done', async () => { + putRefusal = { + success: false, + error: { + code: 'SETTINGS_VALIDATION', + message: "Settings for 'localization' are incomplete: default_country — …", + details: { + namespace: 'localization', + fields: [ + { + field: 'default_country', + code: 'invalid_format', + message: 'Default country does not match the expected format.', + label: 'Default country', + }, + ], + }, + }, + }; + renderChecklist(ADMIN); + fireEvent.click(await screen.findByTestId('first-run-checklist-set-language_country')); + fireEvent.click(screen.getByTestId('first-run-checklist-confirm-language_country')); + expect(await screen.findByText('Choose a value for Default country from the list.')).toBeTruthy(); + expect(screen.queryByText(/does not match the expected format/)).toBeNull(); + expect(row('language_country').getAttribute('data-done')).toBe('false'); + expect(toastError).toHaveBeenCalledTimes(1); + }); + + it('zh: the rows read in the reader language', async () => { + renderChecklist(ADMIN, 'zh'); + expect(await screen.findByText('完成初始设置')).toBeTruthy(); + expect(await screen.findByText('尚未设置,金额暂不显示币种。')).toBeTruthy(); + }); +}); + +describe('the reads wait for the first paint (objectui#12980)', () => { + let splash: HTMLElement | null = null; + + beforeEach(() => { + __resetBootPainted(); + splash = document.createElement('div'); + splash.setAttribute('data-boot-splash', ''); + document.body.appendChild(splash); + }); + + afterEach(() => { + splash?.remove(); + splash = null; + }); + + it('reads nothing while the boot splash is up, and draws once it is gone', async () => { + renderChecklist(ADMIN); + await act(() => new Promise((r) => setTimeout(r, 80))); + expect(calls('GET', '/api/settings')).toHaveLength(0); + + act(() => { + splash?.remove(); + splash = null; + }); + expect(await screen.findByTestId('first-run-checklist')).toBeTruthy(); + expect(calls('GET', '/api/settings')).toHaveLength(1); + }); +}); diff --git a/ui/apps/console/src/pages/settings/__tests__/SettingsField.domainOptionTypedName-30206.test.tsx b/ui/apps/console/src/pages/settings/__tests__/SettingsField.domainOptionTypedName-30206.test.tsx new file mode 100644 index 00000000000..40ae99b2da9 --- /dev/null +++ b/ui/apps/console/src/pages/settings/__tests__/SettingsField.domainOptionTypedName-30206.test.tsx @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * SettingsField: a name typed into a domain-bearing select commits that + * option's code (objectstack#30206). + * + * The country picker shows each option as its name and code, 「中国 (CN)」. A + * Chinese administrator typed 「中国」 into 默认国家 and the combobox committed + * the text as typed, so the server's `^[A-Za-z]{2}$` pattern refused it. A + * name that exactly one option carries is now committed as that option's code; + * anything else is still committed verbatim, and the server stays the judge + * of the domain. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, fireEvent } from '@testing-library/react'; +import { useState } from 'react'; + +import { SettingsField } from '../SettingsField'; +import type { Specifier } from '../types'; + +/** `localization.default_country` as the server declares it in `zh` (abridged). */ +const COUNTRY: Specifier = { + type: 'select', + key: 'default_country', + label: '默认国家', + pattern: '^[A-Za-z]{2}$', + valueDomain: 'iso_3166_alpha2', + options: [ + { value: 'US', label: '美国 (US)' }, + { value: 'CN', label: '中国 (CN)' }, + { value: 'DE', label: 'Germany(DE)' }, + { value: 'GB', label: 'United Kingdom (GB)' }, + { value: 'UM', label: 'United Kingdom (UM)' }, + ], +}; + +/** `localization.currency`, whose suggestions read `CODE — Name`. */ +const CURRENCY: Specifier = { + type: 'select', + key: 'currency', + label: '默认货币', + valueDomain: 'iso_4217_currency', + options: [ + { value: 'CNY', label: 'CNY — 人民币' }, + { value: 'USD', label: 'USD — 美元' }, + ], +}; + +function Row({ spec, onCommit }: { spec: Specifier; onCommit: (v: unknown) => void }) { + const [value, setValue] = useState(null); + return ( + { + onCommit(next); + setValue(next); + }} + /> + ); +} + +function renderRow(spec: Specifier) { + const onCommit = vi.fn(); + const view = render(); + const input = view.container.querySelector('input') as HTMLInputElement; + return { ...view, input, onCommit }; +} + +afterEach(cleanup); + +describe('SettingsField: a typed option name commits the option code (objectstack#30206)', () => { + it('「中国」 typed into 默认国家 commits CN, and reads as its option on blur', () => { + const { input, onCommit } = renderRow(COUNTRY); + fireEvent.change(input, { target: { value: '中国' } }); + expect(onCommit).toHaveBeenLastCalledWith('CN'); + fireEvent.blur(input); + expect(input.value).toBe('中国 (CN)'); + }); + + it('matches trimmed and case-insensitively, with full-width brackets too', () => { + const { input, onCommit } = renderRow(COUNTRY); + fireEvent.change(input, { target: { value: ' germany ' } }); + expect(onCommit).toHaveBeenLastCalledWith('DE'); + }); + + it('a name two options carry is not a pick: it is committed verbatim', () => { + const { input, onCommit } = renderRow(COUNTRY); + fireEvent.change(input, { target: { value: 'United Kingdom' } }); + expect(onCommit).toHaveBeenLastCalledWith('United Kingdom'); + }); + + it('part of a name is not a name: it is committed verbatim, as before', () => { + const { input, onCommit } = renderRow(COUNTRY); + fireEvent.change(input, { target: { value: '中' } }); + expect(onCommit).toHaveBeenLastCalledWith('中'); + }); + + it('a currency name commits its code when the label leads with the code', () => { + const { input, onCommit } = renderRow(CURRENCY); + fireEvent.change(input, { target: { value: '人民币' } }); + expect(onCommit).toHaveBeenLastCalledWith('CNY'); + }); +}); diff --git a/ui/apps/console/src/pages/settings/__tests__/SettingsView.crypto-unavailable.test.tsx b/ui/apps/console/src/pages/settings/__tests__/SettingsView.crypto-unavailable.test.tsx index 1b06dbf734f..c2e621f4229 100644 --- a/ui/apps/console/src/pages/settings/__tests__/SettingsView.crypto-unavailable.test.tsx +++ b/ui/apps/console/src/pages/settings/__tests__/SettingsView.crypto-unavailable.test.tsx @@ -343,7 +343,7 @@ describe('SettingsView — the branches this refusal must not disturb', () => { await typeSecretAndSave(container); - const msg = await screen.findByText(/does not match the expected format/i); + const msg = await screen.findByText(/is not in a valid format/i); expect(msg.getAttribute('role')).toBe('alert'); await waitFor(() => { expect(inputFor('Account ID')!.getAttribute('aria-invalid')).toBe('true'); diff --git a/ui/apps/console/src/pages/settings/__tests__/SettingsView.field-errors.test.tsx b/ui/apps/console/src/pages/settings/__tests__/SettingsView.field-errors.test.tsx index 95791279d5d..05323a44dce 100644 --- a/ui/apps/console/src/pages/settings/__tests__/SettingsView.field-errors.test.tsx +++ b/ui/apps/console/src/pages/settings/__tests__/SettingsView.field-errors.test.tsx @@ -31,6 +31,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; const getSettingsNamespace = vi.fn(); const saveSettingsNamespace = vi.fn(); @@ -106,13 +107,20 @@ function validationRejection() { return err; } +/** + * Mounts an explicit `en` I18nProvider: the format miss below reads in the + * console's words (objectstack#30206), so the pack must be loaded for the + * assertion to read a sentence rather than a key. + */ function renderView() { return render( - - - } /> - - , + + + + } /> + + + , ); } @@ -136,8 +144,10 @@ describe('SettingsView — a rejected save marks the field that caused it', () = fireEvent.change(inputFor('Gateway model')!, { target: { value: 'gpt4o' } }); fireEvent.click(await screen.findByRole('button', { name: /save/i })); - // The server's own sentence, verbatim — not re-worded by the console. - const msg = await screen.findByText(/does not match the expected format/i); + // A format miss (`invalid_format`) reads in the console's words, naming the + // row by its label and repeating the help line it replaces + // (objectstack#30206); any other code keeps the server's sentence. + const msg = await screen.findByText('Gateway model is not in a valid format. Use provider/model.'); expect(msg).toBeTruthy(); expect(msg.getAttribute('role')).toBe('alert'); @@ -164,7 +174,7 @@ describe('SettingsView — a rejected save marks the field that caused it', () = fireEvent.change(inputFor('Gateway model')!, { target: { value: 'gpt4o' } }); fireEvent.click(await screen.findByRole('button', { name: /save/i })); - await screen.findByText(/does not match the expected format/i); + await screen.findByText(/is not in a valid format/i); // Gone — the two say the same kind of thing and share one slot. expect(screen.queryByText('Use provider/model.')).toBeNull(); @@ -179,12 +189,12 @@ describe('SettingsView — a rejected save marks the field that caused it', () = fireEvent.change(inputFor('Gateway model')!, { target: { value: 'gpt4o' } }); fireEvent.click(await screen.findByRole('button', { name: /save/i })); - await screen.findByText(/does not match the expected format/i); + await screen.findByText(/is not in a valid format/i); // Typing contradicts an error describing the value the server saw. fireEvent.change(inputFor('Gateway model')!, { target: { value: 'anthropic/claude' } }); await waitFor(() => { - expect(screen.queryByText(/does not match the expected format/i)).toBeNull(); + expect(screen.queryByText(/is not in a valid format/i)).toBeNull(); }); expect(inputFor('Gateway model')!.getAttribute('aria-invalid')).toBeNull(); // …and the help text comes back with it. @@ -198,7 +208,7 @@ describe('SettingsView — a rejected save marks the field that caused it', () = fireEvent.change(inputFor('Gateway model')!, { target: { value: 'gpt4o' } }); fireEvent.click(await screen.findByRole('button', { name: /save/i })); - await screen.findByText(/does not match the expected format/i); + await screen.findByText(/is not in a valid format/i); saveSettingsNamespace.mockResolvedValueOnce({ values: { gateway_model: { value: 'anthropic/claude', source: 'tenant' } }, @@ -207,7 +217,7 @@ describe('SettingsView — a rejected save marks the field that caused it', () = fireEvent.click(await screen.findByRole('button', { name: /save/i })); await waitFor(() => expect(toastSuccess).toHaveBeenCalled()); - expect(screen.queryByText(/does not match the expected format/i)).toBeNull(); + expect(screen.queryByText(/is not in a valid format/i)).toBeNull(); }); it('a failure carrying no field array still toasts, and marks nothing', async () => { diff --git a/ui/apps/console/src/pages/settings/__tests__/SettingsView.refusalToast-13146.test.tsx b/ui/apps/console/src/pages/settings/__tests__/SettingsView.refusalToast-13146.test.tsx index 3e7944554e8..3ac13c4e5ce 100644 --- a/ui/apps/console/src/pages/settings/__tests__/SettingsView.refusalToast-13146.test.tsx +++ b/ui/apps/console/src/pages/settings/__tests__/SettingsView.refusalToast-13146.test.tsx @@ -57,13 +57,16 @@ const LOCALIZATION = { }, }; +const POLICY_HELP = 'Recorded automatically (UTC, ISO 8601) the first time the server runs with this policy.'; +const POLICY_MESSAGE = `Key policy started at does not match the expected format. ${POLICY_HELP}`; + const AUTH = { manifest: { namespace: 'auth', version: 1, label: 'Authentication', specifiers: [ - { type: 'text', key: 'api_key_policy_started_at', label: 'Key policy started at' }, + { type: 'text', key: 'api_key_policy_started_at', label: 'Key policy started at', description: POLICY_HELP }, ], }, values: { @@ -74,9 +77,7 @@ const AUTH = { const TIMEZONE_MESSAGE = 'Default timezone must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "Mars/Olympus")'; const CURRENCY_MESSAGE = 'Default currency must be a valid ISO 4217 currency code (got "XYZ")'; -const POLICY_MESSAGE = - 'Key policy started at does not match the expected format. Recorded automatically (UTC, ISO 8601) ' + - 'the first time the server runs with this policy.'; + interface Refused { field: string; @@ -206,7 +207,14 @@ describe('a field-by-field refusal toasts the settings by label (objectui#13146) expect(msg).not.toContain('Recorded automatically'); expect(msg).not.toMatch(/incomplete/i); - expect(await screen.findByText(POLICY_MESSAGE)).toBeTruthy(); + // The row says the format miss in the console's words, with the help line + // the refusal replaces (objectstack#30206), not the server's sentence. + expect( + await screen.findByText( + VIEW.invalidFormatHelp.replace('{{setting}}', 'Key policy started at').replace('{{help}}', POLICY_HELP), + ), + ).toBeTruthy(); + expect(screen.queryByText(POLICY_MESSAGE)).toBeNull(); }); it('two refused settings: the plural slot, both labels joined by the pack separator', async () => { diff --git a/ui/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.afterBootPaint-12980.test.tsx b/ui/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.afterBootPaint-12980.test.tsx deleted file mode 100644 index 9cf1171471b..00000000000 --- a/ui/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.afterBootPaint-12980.test.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/** - * objectui#12980, part 3 — the timezone prompt's settings read waits for the - * console's first paint (`whenBootPainted`). The prompt mounts beside the - * routes, under the boot splash, and a dialog has nothing to say before the - * page it sits over has painted. - * - * (a) an administrator who would be asked, the boot splash up: no - * `GET /api/settings`, however long the splash is up; - * (b) the splash gone and a frame painted: the read goes out and the prompt - * opens, exactly as before. - * - * The harness is `WorkspaceTimezonePrompt.test.tsx`'s. - */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { act, cleanup, render, screen } from '@testing-library/react'; -import { I18nProvider } from '@object-ui/i18n'; -import { MePermissionsProvider, type MePermissionsResponse, type MePermissionsAuthenticatedResponse } from '@object-ui/permissions'; - -const { AUTH, toastError, toastSuccess } = vi.hoisted(() => ({ - AUTH: { user: { id: 'u_admin' }, activeOrganization: { id: 'org_1' } }, - toastError: vi.fn(), - toastSuccess: vi.fn(), -})); - -vi.mock('@object-ui/auth', async (importOriginal) => ({ - ...(await importOriginal>()), - useAuth: () => AUTH, -})); -vi.mock('sonner', () => ({ toast: { error: toastError, success: toastSuccess } })); - -import { WorkspaceTimezonePrompt } from '../WorkspaceTimezonePrompt'; -import { __resetBootPainted } from '../../../loadLocales'; -import type { Specifier } from '../types'; - -const BROWSER_ZONE = 'Asia/Kolkata'; - -/** The manifest's `timezone` row as the server declares it (abridged options). */ -const TIMEZONE_SPEC: Specifier = { - type: 'select', - key: 'timezone', - label: 'Default timezone', - default: 'UTC', - valueDomain: 'iana_time_zone', - options: [ - { value: 'UTC', label: 'UTC' }, - { value: 'Asia/Shanghai', label: '(UTC+08) Shanghai' }, - ], -}; - -function localizationManifest(specifiers: Specifier[] = [TIMEZONE_SPEC]) { - return { - namespace: 'localization', - version: 2, - label: 'Localization', - scope: 'tenant', - readPermission: 'setup.access', - writePermission: 'setup.write', - specifiers: [{ type: 'group', id: 'region', label: 'Region' }, ...specifiers], - }; -} - -type Source = 'env' | 'global' | 'tenant' | 'user' | 'default'; - -/** `GET /api/settings/localization` with `timezone` resolved from `source`. */ -function namespacePayload(source: Source, value = 'UTC', specifiers?: Specifier[]) { - const locked = source === 'env'; - return { - manifest: localizationManifest(specifiers), - values: { - timezone: { - value, - source, - locked, - cascadeChain: - source === 'default' - ? [{ scope: 'default', value: 'UTC', effective: true }] - : [ - { scope: source, value, locked, effective: true }, - { scope: 'default', value: 'UTC' }, - ], - }, - }, - }; -} - -function ok(data: unknown) { - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ success: true, data }), - } as unknown as Response; -} - -const fetchMock = vi.fn(); -let served = namespacePayload('default'); - -function routeFetch() { - fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { - const method = init?.method ?? 'GET'; - if (url === '/api/settings' && method === 'GET') { - return ok({ manifests: [served.manifest] }); - } - if (url === '/api/settings/localization' && method === 'GET') return ok(served); - if (url === '/api/settings/localization' && method === 'PUT') { - const body = JSON.parse(String(init?.body)); - return ok({ values: { timezone: { value: body.timezone, source: 'tenant', locked: false } } }); - } - return { ok: false, status: 404, statusText: 'Not Found', json: async () => ({}) } as unknown as Response; - }); -} - -const settingsCalls = (method: string, url = '/api/settings/localization') => - fetchMock.mock.calls.filter(([u, init]) => u === url && ((init as RequestInit | undefined)?.method ?? 'GET') === method); - -function permissionsPayload(systemPermissions: string[] | undefined): MePermissionsResponse { - const base: MePermissionsAuthenticatedResponse = { - authenticated: true, - userId: 'u_admin', - tenantId: 'org_1', - positions: [], - hasAnyQueue: false, - permissionSets: [], - objects: {}, - fields: {}, - }; - return systemPermissions === undefined ? base : { ...base, systemPermissions }; -} - -const ADMIN = ['manage_org_users', 'setup.access', 'setup.write']; - -/** - * No default for `systemPermissions`: a default parameter would turn the - * "not reported" case (`undefined`) into the admin set without a word. - */ -function renderPrompt(systemPermissions: string[] | undefined, language = 'en') { - return render( - - - - - , - ); -} - -let browserZone: string | undefined = BROWSER_ZONE; -const realResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions; - -beforeEach(() => { - fetchMock.mockReset(); - toastError.mockReset(); - toastSuccess.mockReset(); - localStorage.clear(); - served = namespacePayload('default'); - browserZone = BROWSER_ZONE; - vi.stubGlobal('fetch', fetchMock); - routeFetch(); - vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function ( - this: Intl.DateTimeFormat, - ) { - return { ...realResolvedOptions.call(this), timeZone: browserZone as string }; - }); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - - -describe('the settings read waits for the first paint (objectui#12980)', () => { - let splash: HTMLElement | null = null; - - beforeEach(() => { - __resetBootPainted(); - splash = document.createElement('div'); - splash.setAttribute('data-boot-splash', ''); - document.body.appendChild(splash); - }); - - afterEach(() => { - splash?.remove(); - splash = null; - }); - - it('(a) asks nothing while the boot splash is up, (b) reads and opens once it is gone', async () => { - renderPrompt(ADMIN, 'en'); - await act(() => new Promise((r) => setTimeout(r, 80))); - expect(settingsCalls('GET', '/api/settings')).toHaveLength(0); - expect(screen.queryByRole('dialog')).toBeNull(); - - act(() => { - splash?.remove(); - splash = null; - }); - expect(await screen.findByRole('dialog')).toBeTruthy(); - expect(settingsCalls('GET', '/api/settings')).toHaveLength(1); - }); -}); diff --git a/ui/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.currency-28872.test.tsx b/ui/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.currency-28872.test.tsx deleted file mode 100644 index abaa14e9564..00000000000 --- a/ui/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.currency-28872.test.tsx +++ /dev/null @@ -1,433 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * The workspace prompt's currency arm (objectstack#28872): while - * `localization.currency` has no value on any rung, an administrator is asked - * once, offered the currency of the browser's region, and when the timezone - * is still the default too it is ONE prompt with both fields. - * - * The same harness as `WorkspaceTimezonePrompt.test.tsx`: the REAL settings - * client (`api.ts`) against a stubbed `fetch`, so a write is counted as - * `PUT /api/settings/localization` requests on the wire, and the capability - * answer comes from the real `MePermissionsProvider`. The browser's region is - * stubbed at its one reader, `navigator.languages` / `navigator.language`, to - * `zh-CN`, whose currency (CNY) is no curated option's first entry, so an - * offer read from anything else would show. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { I18nProvider } from '@object-ui/i18n'; -import { MePermissionsProvider, type MePermissionsResponse, type MePermissionsAuthenticatedResponse } from '@object-ui/permissions'; - -const { AUTH, toastError, toastSuccess } = vi.hoisted(() => ({ - AUTH: { user: { id: 'u_admin' }, activeOrganization: { id: 'org_1' } }, - toastError: vi.fn(), - toastSuccess: vi.fn(), -})); - -vi.mock('@object-ui/auth', async (importOriginal) => ({ - ...(await importOriginal>()), - useAuth: () => AUTH, -})); -vi.mock('sonner', () => ({ toast: { error: toastError, success: toastSuccess } })); - -import { WorkspaceTimezonePrompt } from '../WorkspaceTimezonePrompt'; -import { browserCurrency, browserLanguage, isAdmissibleValue, promptRecordKey } from '../workspaceTimezonePromptLogic'; -import type { ResolvedSettingValue, Specifier } from '../types'; - -const BROWSER_ZONE = 'Asia/Kolkata'; -const RECORD_KEY = promptRecordKey('org_1', 'u_admin'); - -const TIMEZONE_SPEC: Specifier = { - type: 'select', - key: 'timezone', - label: 'Default timezone', - default: 'UTC', - valueDomain: 'iana_time_zone', - options: [ - { value: 'UTC', label: 'UTC' }, - { value: 'Asia/Shanghai', label: '(UTC+08) Shanghai' }, - ], -}; - -/** The manifest's `currency` row as the server declares it: no default (abridged options). */ -const CURRENCY_SPEC: Specifier = { - type: 'select', - key: 'currency', - label: 'Default currency', - valueDomain: 'iso_4217_currency', - options: [ - { value: 'USD', label: 'USD — US Dollar' }, - { value: 'EUR', label: 'EUR — Euro' }, - { value: 'CNY', label: 'CNY — Chinese Yuan' }, - ], -}; - -type Source = 'env' | 'global' | 'tenant' | 'user' | 'default'; - -function resolvedValue(source: Source, value: unknown, defaultValue: unknown): ResolvedSettingValue { - const locked = source === 'env'; - return { - value, - source, - locked, - cascadeChain: - source === 'default' - ? [{ scope: 'default', value: defaultValue, effective: true }] - : [ - { scope: source, value, locked, effective: true }, - { scope: 'default', value: defaultValue }, - ], - }; -} - -interface Served { - timezone?: [Source, string]; - currency?: [Source, string | null]; - currencySpec?: Specifier; -} - -/** `GET /api/settings/localization`. Unset currency = `source: 'default'`, `value: null`, as the service answers it. */ -function namespacePayload({ timezone = ['default', 'UTC'], currency = ['default', null], currencySpec = CURRENCY_SPEC }: Served = {}) { - return { - manifest: { - namespace: 'localization', - version: 2, - label: 'Localization', - scope: 'tenant', - readPermission: 'setup.access', - writePermission: 'setup.write', - specifiers: [ - { type: 'group', id: 'region', label: 'Region' }, - TIMEZONE_SPEC, - { type: 'group', id: 'finance', label: 'Finance' }, - currencySpec, - ], - }, - values: { - timezone: resolvedValue(timezone[0], timezone[1], 'UTC'), - currency: resolvedValue(currency[0], currency[1], currencySpec.default ?? null), - }, - }; -} - -function ok(data: unknown) { - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ success: true, data }), - } as unknown as Response; -} - -const fetchMock = vi.fn(); -let served = namespacePayload(); - -function routeFetch() { - fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { - const method = init?.method ?? 'GET'; - if (url === '/api/settings' && method === 'GET') { - return ok({ manifests: [served.manifest] }); - } - if (url === '/api/settings/localization' && method === 'GET') return ok(served); - if (url === '/api/settings/localization' && method === 'PUT') { - const body = JSON.parse(String(init?.body)) as Record; - const values = Object.fromEntries( - Object.entries(body).map(([key, value]) => [key, { value, source: 'tenant', locked: false }]), - ); - return ok({ values }); - } - return { ok: false, status: 404, statusText: 'Not Found', json: async () => ({}) } as unknown as Response; - }); -} - -const settingsCalls = (method: string, url = '/api/settings/localization') => - fetchMock.mock.calls.filter(([u, init]) => u === url && ((init as RequestInit | undefined)?.method ?? 'GET') === method); - -const putBodies = () => settingsCalls('PUT').map(([, init]) => JSON.parse(String((init as RequestInit).body))); - -function permissionsPayload(systemPermissions: string[] | undefined): MePermissionsResponse { - const base: MePermissionsAuthenticatedResponse = { - authenticated: true, - userId: 'u_admin', - tenantId: 'org_1', - positions: [], - hasAnyQueue: false, - permissionSets: [], - objects: {}, - fields: {}, - }; - return systemPermissions === undefined ? base : { ...base, systemPermissions }; -} - -const ADMIN = ['manage_org_users', 'setup.access', 'setup.write']; - -function renderPrompt(systemPermissions: string[] | undefined, language = 'en') { - return render( - - - - - , - ); -} - -async function settle() { - await act(async () => { - for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); - }); -} - -let browserZone: string | undefined = BROWSER_ZONE; -const realResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions; - -function stubBrowserLanguage(tags: readonly string[]) { - vi.spyOn(window.navigator, 'languages', 'get').mockReturnValue(tags as string[]); - vi.spyOn(window.navigator, 'language', 'get').mockReturnValue(tags[0] ?? ''); -} - -beforeEach(() => { - fetchMock.mockReset(); - toastError.mockReset(); - toastSuccess.mockReset(); - localStorage.clear(); - served = namespacePayload({ timezone: ['tenant', 'Asia/Shanghai'] }); - browserZone = BROWSER_ZONE; - vi.stubGlobal('fetch', fetchMock); - routeFetch(); - vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function ( - this: Intl.DateTimeFormat, - ) { - return { ...realResolvedOptions.call(this), timeZone: browserZone as string }; - }); - stubBrowserLanguage(['zh-CN', 'en-US']); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -describe('the currency arm alone: the timezone is chosen, the currency is unset (objectstack#28872)', () => { - it('en: asks once, offering the browser region\'s currency by name and code', async () => { - renderPrompt(ADMIN, 'en'); - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByText('Set the default currency')).toBeTruthy(); - expect(within(dialog).getByText(/no default currency yet/)).toBeTruthy(); - expect(within(dialog).getByText(/Chinese Yuan \(CNY\)/)).toBeTruthy(); - // The combobox shows the curated option's label for the pre-filled code. - const field = within(dialog).getByLabelText('Default currency') as HTMLInputElement; - expect(field.value).toBe('CNY — Chinese Yuan'); - // No timezone field: that key is chosen. - expect(within(dialog).queryByLabelText('Default timezone')).toBeNull(); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - expect(localStorage.getItem(RECORD_KEY)).not.toBeNull(); - expect(settingsCalls('PUT')).toHaveLength(0); - }); - - it('zh: the copy reads in the pack, naming the offer as 人民币(CNY)', async () => { - renderPrompt(ADMIN, 'zh'); - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByText('设置默认货币')).toBeTruthy(); - expect(within(dialog).getByText(/本组织尚未设置默认货币,金额暂不显示币种/)).toBeTruthy(); - expect(within(dialog).getByText(/人民币(CNY)/)).toBeTruthy(); - expect(within(dialog).getByRole('button', { name: '暂不设置' })).toBeTruthy(); - expect(within(dialog).getByRole('button', { name: '设置货币' })).toBeTruthy(); - }); - - it('confirm → exactly one PUT carrying the currency alone', async () => { - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - fireEvent.click(within(dialog).getByRole('button', { name: 'Set currency' })); - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect(putBodies()).toEqual([{ currency: 'CNY' }]); - expect(toastSuccess).toHaveBeenCalledWith('Default currency set to CNY'); - }); - - it('an edited currency is the one written', async () => { - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - fireEvent.change(within(dialog).getByLabelText('Default currency'), { target: { value: 'EUR' } }); - fireEvent.click(within(dialog).getByRole('button', { name: 'Set currency' })); - await waitFor(() => expect(settingsCalls('PUT')).toHaveLength(1)); - expect(putBodies()).toEqual([{ currency: 'EUR' }]); - }); - - it('decline → no write, and no second prompt when the shell mounts again', async () => { - const first = renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - fireEvent.click(within(dialog).getByRole('button', { name: 'Not now' })); - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect(settingsCalls('PUT')).toHaveLength(0); - first.unmount(); - - const readsBefore = fetchMock.mock.calls.length; - renderPrompt(ADMIN); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - expect(fetchMock.mock.calls.length).toBe(readsBefore); - }); - - it('a refused currency lands in its field\'s error slot and the prompt stays open', async () => { - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - fetchMock.mockImplementationOnce(async () => ({ - ok: false, - status: 400, - statusText: 'Bad Request', - json: async () => ({ - success: false, - error: { - code: 'SETTINGS_VALIDATION', - message: "Settings for 'localization' are invalid: currency", - details: { fields: [{ field: 'currency', code: 'value_domain', message: 'Not an ISO 4217 code' }] }, - }, - }), - })); - fireEvent.click(within(dialog).getByRole('button', { name: 'Set currency' })); - expect(await within(dialog).findByRole('alert')).toBeTruthy(); - expect(within(dialog).getByText('Not an ISO 4217 code')).toBeTruthy(); - expect(screen.getByRole('dialog')).toBeTruthy(); - }); - - it('no browser zone at all: the currency is still asked about', async () => { - browserZone = undefined; - served = namespacePayload(); - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByText('Set the default currency')).toBeTruthy(); - expect(within(dialog).queryByLabelText('Default timezone')).toBeNull(); - }); -}); - -describe('both unset: ONE prompt, one write (objectstack#28872)', () => { - beforeEach(() => { - served = namespacePayload(); - }); - - it('one dialog carries both fields, each pre-filled from the browser', async () => { - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - expect(within(dialog).getByText('Set the system timezone and default currency')).toBeTruthy(); - expect((within(dialog).getByLabelText('Default timezone') as HTMLInputElement).value).toBe(BROWSER_ZONE); - expect((within(dialog).getByLabelText('Default currency') as HTMLInputElement).value).toBe('CNY — Chinese Yuan'); - expect(within(dialog).getByText(/platform default timezone, UTC\./)).toBeTruthy(); - expect(within(dialog).getByText(/Chinese Yuan \(CNY\)/)).toBeTruthy(); - // Settings read once for both arms. - expect(settingsCalls('GET')).toHaveLength(1); - }); - - it('confirm → exactly one PUT carrying both keys', async () => { - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - fireEvent.click(within(dialog).getByRole('button', { name: 'Save' })); - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect(putBodies()).toEqual([{ timezone: BROWSER_ZONE, currency: 'CNY' }]); - expect(toastSuccess).toHaveBeenCalledWith( - `System timezone set to ${BROWSER_ZONE} and default currency set to CNY`, - ); - }); - - it('a cleared currency field is left unset: the PUT carries the timezone alone', async () => { - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - fireEvent.change(within(dialog).getByLabelText('Default currency'), { target: { value: '' } }); - fireEvent.click(within(dialog).getByRole('button', { name: 'Save' })); - await waitFor(() => expect(settingsCalls('PUT')).toHaveLength(1)); - expect(putBodies()).toEqual([{ timezone: BROWSER_ZONE }]); - expect(toastSuccess).toHaveBeenCalledWith(`System timezone set to ${BROWSER_ZONE}`); - }); - - it('decline → no write for either key', async () => { - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - fireEvent.click(within(dialog).getByRole('button', { name: 'Not now' })); - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect(settingsCalls('PUT')).toHaveLength(0); - }); -}); - -describe('a currency with a value, or one this session may not write, is never asked about (objectstack#28872)', () => { - it.each([ - ['tenant', 'EUR'], - ['global', 'USD'], - ['env', 'JPY'], - ] as const)('%s-sourced currency → no prompt, no write, no record', async (source, value) => { - served = namespacePayload({ timezone: ['tenant', 'Asia/Shanghai'], currency: [source, value] }); - renderPrompt(ADMIN); - await waitFor(() => expect(settingsCalls('GET')).toHaveLength(1)); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - expect(settingsCalls('PUT')).toHaveLength(0); - expect(localStorage.getItem(RECORD_KEY)).toBeNull(); - }); - - it('a manifest that declares a default currency has one → no prompt', async () => { - served = namespacePayload({ - timezone: ['tenant', 'Asia/Shanghai'], - currency: ['default', 'USD'], - currencySpec: { ...CURRENCY_SPEC, default: 'USD' }, - }); - renderPrompt(ADMIN); - await waitFor(() => expect(settingsCalls('GET')).toHaveLength(1)); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - }); - - it('a non-admin (reads settings, lacks the write permission) → no prompt, no namespace read', async () => { - served = namespacePayload(); - renderPrompt(['setup.access']); - await waitFor(() => expect(settingsCalls('GET', '/api/settings')).toHaveLength(1)); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - expect(settingsCalls('GET')).toHaveLength(0); - expect(settingsCalls('PUT')).toHaveLength(0); - }); - - it('a browser language with no country region (es-419) offers no currency → no prompt', async () => { - stubBrowserLanguage(['es-419', 'es']); - renderPrompt(ADMIN); - await waitFor(() => expect(settingsCalls('GET')).toHaveLength(1)); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - }); - - it('no browser zone and no browser currency → no request at all', async () => { - browserZone = undefined; - stubBrowserLanguage(['es-419']); - served = namespacePayload(); - renderPrompt(ADMIN); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); - -describe('the browser reader and the door predicate (objectstack#28872)', () => { - it('browserLanguage reads the FIRST preference only; browserCurrency maps it through the spec table', () => { - expect(browserLanguage()).toBe('zh-CN'); - expect(browserCurrency()).toBe('CNY'); - stubBrowserLanguage(['es-419', 'es']); - expect(browserLanguage()).toBe('es-419'); - expect(browserCurrency()).toBeUndefined(); - stubBrowserLanguage(['ja']); - expect(browserCurrency()).toBe('JPY'); - }); - - it('isAdmissibleValue: the iso_4217_currency domain decides, not the curated options', () => { - expect(isAdmissibleValue(CURRENCY_SPEC, 'JPY')).toBe(true); - expect(isAdmissibleValue(CURRENCY_SPEC, 'XXX')).toBe(false); - const closed: Specifier = { ...CURRENCY_SPEC, valueDomain: undefined }; - expect(isAdmissibleValue(closed, 'CNY')).toBe(true); - expect(isAdmissibleValue(closed, 'JPY')).toBe(false); - }); -}); diff --git a/ui/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.test.tsx b/ui/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.test.tsx deleted file mode 100644 index 778ebc4e705..00000000000 --- a/ui/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.test.tsx +++ /dev/null @@ -1,375 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * The one-time workspace-timezone prompt (objectui#11758) — the gate the - * objectui#11693 ruling (B2) put on PR #11729. - * - * Every pin drives the REAL settings client (`api.ts`) against a stubbed - * `fetch`, so "exactly one write" counts `PUT /api/settings/localization` - * requests on the wire rather than calls on a mock, and the capability answer - * comes from the real `MePermissionsProvider`. - * - * The browser's zone is stubbed at its one reader, - * `Intl.DateTimeFormat.prototype.resolvedOptions`, to a zone that is NOT the - * default (`Asia/Kolkata` against `UTC`), so a pre-fill that read anything - * else would show. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { I18nProvider } from '@object-ui/i18n'; -import { MePermissionsProvider, type MePermissionsResponse, type MePermissionsAuthenticatedResponse } from '@object-ui/permissions'; - -const { AUTH, toastError, toastSuccess } = vi.hoisted(() => ({ - AUTH: { user: { id: 'u_admin' }, activeOrganization: { id: 'org_1' } }, - toastError: vi.fn(), - toastSuccess: vi.fn(), -})); - -vi.mock('@object-ui/auth', async (importOriginal) => ({ - ...(await importOriginal>()), - useAuth: () => AUTH, -})); -vi.mock('sonner', () => ({ toast: { error: toastError, success: toastSuccess } })); - -import { WorkspaceTimezonePrompt } from '../WorkspaceTimezonePrompt'; -import { - browserTimeZone, - isAdmissibleZone, - mayWrite, - promptRecordKey, -} from '../workspaceTimezonePromptLogic'; -import type { Specifier } from '../types'; - -const BROWSER_ZONE = 'Asia/Kolkata'; -const RECORD_KEY = promptRecordKey('org_1', 'u_admin'); - -/** The manifest's `timezone` row as the server declares it (abridged options). */ -const TIMEZONE_SPEC: Specifier = { - type: 'select', - key: 'timezone', - label: 'Default timezone', - default: 'UTC', - valueDomain: 'iana_time_zone', - options: [ - { value: 'UTC', label: 'UTC' }, - { value: 'Asia/Shanghai', label: '(UTC+08) Shanghai' }, - ], -}; - -function localizationManifest(specifiers: Specifier[] = [TIMEZONE_SPEC]) { - return { - namespace: 'localization', - version: 2, - label: 'Localization', - scope: 'tenant', - readPermission: 'setup.access', - writePermission: 'setup.write', - specifiers: [{ type: 'group', id: 'region', label: 'Region' }, ...specifiers], - }; -} - -type Source = 'env' | 'global' | 'tenant' | 'user' | 'default'; - -/** `GET /api/settings/localization` with `timezone` resolved from `source`. */ -function namespacePayload(source: Source, value = 'UTC', specifiers?: Specifier[]) { - const locked = source === 'env'; - return { - manifest: localizationManifest(specifiers), - values: { - timezone: { - value, - source, - locked, - cascadeChain: - source === 'default' - ? [{ scope: 'default', value: 'UTC', effective: true }] - : [ - { scope: source, value, locked, effective: true }, - { scope: 'default', value: 'UTC' }, - ], - }, - }, - }; -} - -function ok(data: unknown) { - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ success: true, data }), - } as unknown as Response; -} - -const fetchMock = vi.fn(); -let served = namespacePayload('default'); - -function routeFetch() { - fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { - const method = init?.method ?? 'GET'; - if (url === '/api/settings' && method === 'GET') { - return ok({ manifests: [served.manifest] }); - } - if (url === '/api/settings/localization' && method === 'GET') return ok(served); - if (url === '/api/settings/localization' && method === 'PUT') { - const body = JSON.parse(String(init?.body)); - return ok({ values: { timezone: { value: body.timezone, source: 'tenant', locked: false } } }); - } - return { ok: false, status: 404, statusText: 'Not Found', json: async () => ({}) } as unknown as Response; - }); -} - -const settingsCalls = (method: string, url = '/api/settings/localization') => - fetchMock.mock.calls.filter(([u, init]) => u === url && ((init as RequestInit | undefined)?.method ?? 'GET') === method); - -function permissionsPayload(systemPermissions: string[] | undefined): MePermissionsResponse { - const base: MePermissionsAuthenticatedResponse = { - authenticated: true, - userId: 'u_admin', - tenantId: 'org_1', - positions: [], - hasAnyQueue: false, - permissionSets: [], - objects: {}, - fields: {}, - }; - return systemPermissions === undefined ? base : { ...base, systemPermissions }; -} - -const ADMIN = ['manage_org_users', 'setup.access', 'setup.write']; - -/** - * No default for `systemPermissions`: a default parameter would turn the - * "not reported" case (`undefined`) into the admin set without a word. - */ -function renderPrompt(systemPermissions: string[] | undefined, language = 'en') { - return render( - - - - - , - ); -} - -/** Let the decision's request chain settle, then nothing more is pending. */ -async function settle() { - await act(async () => { - for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); - }); -} - -let browserZone: string | undefined = BROWSER_ZONE; -const realResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions; - -beforeEach(() => { - fetchMock.mockReset(); - toastError.mockReset(); - toastSuccess.mockReset(); - localStorage.clear(); - served = namespacePayload('default'); - browserZone = BROWSER_ZONE; - vi.stubGlobal('fetch', fetchMock); - routeFetch(); - vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function ( - this: Intl.DateTimeFormat, - ) { - return { ...realResolvedOptions.call(this), timeZone: browserZone as string }; - }); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -describe('asks once while the zone is the default and the session may write it (objectui#11758)', () => { - it('en: renders once, pre-filled with the browser zone, naming the current default', async () => { - renderPrompt(ADMIN, 'en'); - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByText('Set the system timezone')).toBeTruthy(); - expect(within(dialog).getByText(/platform default timezone, UTC\./)).toBeTruthy(); - // The Settings page's own field for the key, pre-filled with the browser's zone. - const field = within(dialog).getByLabelText('Default timezone') as HTMLInputElement; - expect(field.value).toBe(BROWSER_ZONE); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - // Asked and recorded, nothing written. - expect(localStorage.getItem(RECORD_KEY)).not.toBeNull(); - expect(settingsCalls('PUT')).toHaveLength(0); - }); - - it('zh-CN: the same prompt reads in the pack, pre-filled the same way', async () => { - renderPrompt(ADMIN, 'zh'); - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByText('设置系统时区')).toBeTruthy(); - expect(within(dialog).getByRole('button', { name: '保持默认' })).toBeTruthy(); - expect(within(dialog).getByRole('button', { name: '设置时区' })).toBeTruthy(); - expect(screen.queryByText('Set the system timezone')).toBeNull(); - // An input carrying a suggestion `list` is a combobox to assistive tech. - expect((within(dialog).getByRole('combobox') as HTMLInputElement).value).toBe(BROWSER_ZONE); - }); -}); - -describe('a chosen zone is never asked about (objectui#11758)', () => { - it.each([ - ['tenant', 'Europe/Paris'], - ['global', 'America/New_York'], - ['env', 'Asia/Tokyo'], - ] as const)('%s-sourced timezone → no prompt, no write', async (source, value) => { - served = namespacePayload(source, value); - renderPrompt(ADMIN); - await waitFor(() => expect(settingsCalls('GET')).toHaveLength(1)); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - expect(settingsCalls('PUT')).toHaveLength(0); - // Not asked, so nothing recorded: the record means "this admin was asked". - expect(localStorage.getItem(RECORD_KEY)).toBeNull(); - }); -}); - -describe('a session that may not write settings is never asked (objectui#11758)', () => { - it('reads settings but lacks the manifest writePermission → no prompt, no write', async () => { - renderPrompt(['setup.access']); - await waitFor(() => expect(settingsCalls('GET', '/api/settings')).toHaveLength(1)); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - expect(settingsCalls('PUT')).toHaveLength(0); - // It never read the namespace either: the list already answered "may not write". - expect(settingsCalls('GET')).toHaveLength(0); - }); - - it.each([ - ['not reported', undefined], - ['reported empty', [] as string[]], - ])('capabilities %s → no prompt, no request at all', async (_label, systemPermissions) => { - renderPrompt(systemPermissions); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); - -describe('confirm writes once through the Settings write path; decline writes nothing (objectui#11758)', () => { - it('confirm → exactly one PUT /api/settings/localization carrying the chosen zone', async () => { - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - const field = within(dialog).getByLabelText('Default timezone'); - fireEvent.change(field, { target: { value: 'Europe/Berlin' } }); - fireEvent.click(within(dialog).getByRole('button', { name: 'Set timezone' })); - - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - const puts = settingsCalls('PUT'); - expect(puts).toHaveLength(1); - expect(JSON.parse(String((puts[0][1] as RequestInit).body))).toEqual({ timezone: 'Europe/Berlin' }); - expect(toastSuccess).toHaveBeenCalledWith('System timezone set to Europe/Berlin'); - }); - - it('confirm without editing writes the pre-filled browser zone', async () => { - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - fireEvent.click(within(dialog).getByRole('button', { name: 'Set timezone' })); - await waitFor(() => expect(settingsCalls('PUT')).toHaveLength(1)); - expect(JSON.parse(String((settingsCalls('PUT')[0][1] as RequestInit).body))).toEqual({ - timezone: BROWSER_ZONE, - }); - }); - - it('decline → no write, and no second prompt when the shell mounts again', async () => { - const first = renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - fireEvent.click(within(dialog).getByRole('button', { name: 'Keep the default' })); - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect(settingsCalls('PUT')).toHaveLength(0); - first.unmount(); - - const readsBefore = fetchMock.mock.calls.length; - renderPrompt(ADMIN); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - // Recorded as asked, so the second mount does not even read the settings. - expect(fetchMock.mock.calls.length).toBe(readsBefore); - expect(settingsCalls('PUT')).toHaveLength(0); - }); - - it('Escape is a decline too: no write', async () => { - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - fireEvent.keyDown(dialog, { key: 'Escape' }); - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect(settingsCalls('PUT')).toHaveLength(0); - }); - - it('a refused zone lands in the field error slot and the prompt stays open', async () => { - renderPrompt(ADMIN); - const dialog = await screen.findByRole('dialog'); - fetchMock.mockImplementationOnce(async () => ({ - ok: false, - status: 400, - statusText: 'Bad Request', - json: async () => ({ - success: false, - error: { - code: 'SETTINGS_VALIDATION', - message: "Settings for 'localization' are invalid: timezone", - details: { fields: [{ field: 'timezone', code: 'value_domain', message: 'Not an IANA time zone' }] }, - }, - }), - })); - fireEvent.click(within(dialog).getByRole('button', { name: 'Set timezone' })); - expect(await within(dialog).findByRole('alert')).toBeTruthy(); - expect(within(dialog).getByText('Not an IANA time zone')).toBeTruthy(); - expect(screen.getByRole('dialog')).toBeTruthy(); - }); -}); - -describe('a browser zone the door would refuse is never offered (objectui#11758)', () => { - it('ICU "Etc/Unknown" → no prompt', async () => { - browserZone = 'Etc/Unknown'; - renderPrompt(ADMIN); - await waitFor(() => expect(settingsCalls('GET')).toHaveLength(1)); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - }); - - it('no browser zone at all → no prompt and no request', async () => { - browserZone = undefined; - // Nor a currency to offer (objectstack#28872): a language with no country - // region maps to none, so the prompt has nothing to ask about either key. - vi.spyOn(window.navigator, 'languages', 'get').mockReturnValue(['es-419']); - vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('es-419'); - renderPrompt(ADMIN); - await settle(); - expect(screen.queryByRole('dialog')).toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('isAdmissibleZone: the declared domain decides; without one, the options table does', () => { - expect(isAdmissibleZone(TIMEZONE_SPEC, 'Asia/Kolkata')).toBe(true); - expect(isAdmissibleZone(TIMEZONE_SPEC, 'Etc/Unknown')).toBe(false); - expect(isAdmissibleZone(TIMEZONE_SPEC, 'Mars/Olympus')).toBe(false); - const closed: Specifier = { ...TIMEZONE_SPEC, valueDomain: undefined }; - expect(isAdmissibleZone(closed, 'Asia/Shanghai')).toBe(true); - expect(isAdmissibleZone(closed, 'Asia/Kolkata')).toBe(false); - }); - - it('browserTimeZone reads the stubbed reader and refuses an empty answer', () => { - expect(browserTimeZone()).toBe(BROWSER_ZONE); - browserZone = ''; - expect(browserTimeZone()).toBeUndefined(); - }); - - it('mayWrite fails closed on an unreported answer and on an undeclared write permission', () => { - expect(mayWrite({ writePermission: 'setup.write' }, ADMIN)).toBe(true); - expect(mayWrite({ writePermission: 'setup.write' }, ['setup.access'])).toBe(false); - expect(mayWrite({ writePermission: 'setup.write' }, undefined)).toBe(false); - expect(mayWrite({ writePermission: undefined }, ADMIN)).toBe(false); - }); -}); diff --git a/ui/apps/console/src/pages/settings/__tests__/firstRunChecklistLogic.test.ts b/ui/apps/console/src/pages/settings/__tests__/firstRunChecklistLogic.test.ts new file mode 100644 index 00000000000..9f03047cc0f --- /dev/null +++ b/ui/apps/console/src/pages/settings/__tests__/firstRunChecklistLogic.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The first-run checklist's conditions (objectstack#30206), as pure functions: + * what each row offers from the browser, and when each row is done. Done is + * read from what the server answers, never from a click, so every case here + * is a server answer in and a verdict out. + */ + +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_ORGANIZATION_NAME, + browserRegion, + dismissRecordKey, + hasInvitedColleagues, + isKeySet, + isMailConfigured, + isOrganizationNamed, + localeOffer, + localizationRows, + markDismissed, + wasDismissed, + type ChecklistStorage, +} from '../firstRunChecklistLogic'; +import type { ResolvedSettingValue, SettingsManifest, Specifier } from '../types'; + +const LOCALE_SPEC: Specifier = { + type: 'select', + key: 'locale', + label: 'Default locale', + default: 'en-US', + options: [ + { value: 'en-US', label: 'English (US)' }, + { value: 'zh-CN', label: '中文(简体)' }, + { value: 'ja-JP', label: '日本語' }, + { value: 'es-ES', label: 'Español' }, + ], +}; +const COUNTRY_SPEC: Specifier = { + type: 'select', + key: 'default_country', + label: 'Default country', + default: 'US', + pattern: '^[A-Za-z]{2}$', + valueDomain: 'iso_3166_alpha2', + options: [ + { value: 'US', label: 'United States (US)' }, + { value: 'CN', label: 'China (CN)' }, + ], +}; +const TIMEZONE_SPEC: Specifier = { + type: 'select', + key: 'timezone', + label: 'Default timezone', + default: 'UTC', + valueDomain: 'iana_time_zone', + options: [{ value: 'UTC', label: 'UTC' }], +}; +const CURRENCY_SPEC: Specifier = { + type: 'select', + key: 'currency', + label: 'Default currency', + valueDomain: 'iso_4217_currency', + options: [{ value: 'USD', label: 'US Dollar (USD)' }], +}; + +function manifest(specifiers: Specifier[] = [LOCALE_SPEC, COUNTRY_SPEC, TIMEZONE_SPEC, CURRENCY_SPEC]): SettingsManifest { + return { + namespace: 'localization', + label: 'Localization', + scope: 'tenant', + writePermission: 'setup.write', + specifiers, + } as unknown as SettingsManifest; +} + +function fromDefault(value: unknown): ResolvedSettingValue { + return { value, source: 'default', locked: false, cascadeChain: [] } as unknown as ResolvedSettingValue; +} +function fromTenant(value: unknown): ResolvedSettingValue { + return { value, source: 'tenant', locked: false, cascadeChain: [] } as unknown as ResolvedSettingValue; +} + +const FRESH: Record = { + locale: fromDefault('en-US'), + default_country: fromDefault('US'), + timezone: fromDefault('UTC'), + currency: fromDefault(null), +}; + +describe('the browser offer: one language decides language, country and currency', () => { + it('offers the locale option for the browser language, by exact tag, then maximized region, then sole language', () => { + expect(localeOffer(LOCALE_SPEC, 'zh-CN')).toBe('zh-CN'); + expect(localeOffer(LOCALE_SPEC, 'zh')).toBe('zh-CN'); + expect(localeOffer(LOCALE_SPEC, 'en-GB')).toBe('en-US'); + expect(localeOffer(LOCALE_SPEC, 'fr-FR')).toBeUndefined(); + expect(localeOffer(LOCALE_SPEC, undefined)).toBeUndefined(); + }); + + it('reads the country from the language, a bare language by its likely region, and never a non-country region', () => { + expect(browserRegion('zh')).toBe('CN'); + expect(browserRegion('en-GB')).toBe('GB'); + expect(browserRegion('es-419')).toBeUndefined(); + expect(browserRegion('')).toBeUndefined(); + }); + + it('pre-fills only what the write door would take, and only for unset keys', () => { + const rows = localizationRows(manifest(), FRESH, { language: 'zh-CN', zone: 'Asia/Shanghai' }); + expect(rows.map((row) => row.id)).toEqual(['language_country', 'timezone', 'currency']); + const offers = Object.fromEntries(rows.flatMap((row) => row.fields.map((f) => [f.key, f.offer]))); + expect(offers).toEqual({ locale: 'zh-CN', default_country: 'CN', timezone: 'Asia/Shanghai', currency: 'CNY' }); + + const unknownZone = localizationRows(manifest(), FRESH, { language: 'zh-CN', zone: 'Etc/Unknown' }); + expect(unknownZone.find((row) => row.id === 'timezone')?.fields[0].offer).toBeUndefined(); + + const set = localizationRows(manifest(), { ...FRESH, timezone: fromTenant('Europe/Berlin') }, { zone: 'Asia/Shanghai' }); + expect(set.find((row) => row.id === 'timezone')?.fields[0]).toMatchObject({ set: true }); + expect(set.find((row) => row.id === 'timezone')?.fields[0].offer).toBeUndefined(); + }); +}); + +describe('done is read from the resolved value', () => { + it('a key with a platform default is set once a rung other than the default holds it', () => { + expect(isKeySet('timezone', fromDefault('UTC'))).toBe(false); + expect(isKeySet('timezone', fromTenant('UTC'))).toBe(true); + expect(isKeySet('locale', { ...fromDefault('en-US'), source: 'env', locked: true } as ResolvedSettingValue)).toBe(true); + }); + + it('the currency, which has no platform default, is set once any rung holds a value', () => { + expect(isKeySet('currency', fromDefault(null))).toBe(false); + expect(isKeySet('currency', fromDefault(''))).toBe(false); + expect(isKeySet('currency', fromDefault('EUR'))).toBe(true); + expect(isKeySet('currency', fromTenant('CNY'))).toBe(true); + }); + + it('a row is done when every key it edits is set, and leaves no row for a key the manifest retired', () => { + const rows = localizationRows(manifest(), { ...FRESH, locale: fromTenant('zh-CN') }, {}); + expect(rows.find((row) => row.id === 'language_country')?.done).toBe(false); + const both = localizationRows(manifest(), { ...FRESH, locale: fromTenant('zh-CN'), default_country: fromTenant('CN') }, {}); + expect(both.find((row) => row.id === 'language_country')?.done).toBe(true); + + const noCountry = localizationRows(manifest([LOCALE_SPEC, TIMEZONE_SPEC, CURRENCY_SPEC]), FRESH, {}); + expect(noCountry.map((row) => row.id)).toEqual(['timezone', 'currency']); + }); + + it('mail is done only when a delivering provider has a From address, and SMTP a host', () => { + expect(isMailConfigured({ provider: fromDefault('smtp') })).toBe(false); + expect(isMailConfigured({ provider: fromTenant('log'), from_email: fromTenant('a@b.co') })).toBe(false); + expect(isMailConfigured({ provider: fromTenant('smtp'), from_email: fromTenant('a@b.co') })).toBe(false); + expect( + isMailConfigured({ provider: fromTenant('smtp'), from_email: fromTenant('a@b.co'), smtp_host: fromTenant('mx') }), + ).toBe(true); + expect(isMailConfigured({ provider: fromTenant('resend'), from_email: fromTenant('a@b.co') })).toBe(true); + }); + + it('the organization is named once it no longer carries the platform name', () => { + expect(DEFAULT_ORGANIZATION_NAME).toBe('Default Organization'); + expect(isOrganizationNamed('Default Organization')).toBe(false); + expect(isOrganizationNamed(' Default Organization ')).toBe(false); + expect(isOrganizationNamed('')).toBe(false); + expect(isOrganizationNamed(undefined)).toBe(false); + expect(isOrganizationNamed('Acme')).toBe(true); + }); + + it('colleagues are invited once there is a second member or a pending invitation', () => { + expect(hasInvitedColleagues([{}], [])).toBe(false); + expect(hasInvitedColleagues([{}], [{ status: 'canceled' }, { status: 'accepted' }])).toBe(false); + expect(hasInvitedColleagues([{}], [{ status: 'pending' }])).toBe(true); + expect(hasInvitedColleagues([{}, {}], [])).toBe(true); + }); +}); + +describe('hiding the checklist is a per-device record, per workspace and user', () => { + function memory(): ChecklistStorage & { data: Map } { + const data = new Map(); + return { data, getItem: (k) => data.get(k) ?? null, setItem: (k, v) => void data.set(k, v) }; + } + + it('keys the record on both ids, and reads it back', () => { + const storage = memory(); + const key = dismissRecordKey('org_1', 'u_admin'); + expect(key).toBe('os:first-run-checklist:org_1:u_admin'); + expect(wasDismissed(storage, key)).toBe(false); + expect(markDismissed(storage, key)).toBe(true); + expect(wasDismissed(storage, key)).toBe(true); + expect(wasDismissed(storage, dismissRecordKey('org_2', 'u_admin'))).toBe(false); + }); + + it('storage that cannot be read shows the checklist rather than hiding it', () => { + const broken: ChecklistStorage = { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => { + throw new Error('denied'); + }, + }; + expect(wasDismissed(broken, 'k')).toBe(false); + expect(wasDismissed(undefined, 'k')).toBe(false); + expect(markDismissed(broken, 'k')).toBe(false); + }); +}); diff --git a/ui/apps/console/src/pages/settings/__tests__/settingsFieldRefusals-30206.test.ts b/ui/apps/console/src/pages/settings/__tests__/settingsFieldRefusals-30206.test.ts new file mode 100644 index 00000000000..b2e19c0286c --- /dev/null +++ b/ui/apps/console/src/pages/settings/__tests__/settingsFieldRefusals-30206.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A settings refusal for a value that misses its format reads in the console's + * words (objectstack#30206). The settings service sends `code: 'invalid_format'` + * with an English sentence for every reader; a Chinese administrator who typed + * 「中国」 into 默认国家 read "Default country does not match the expected + * format." The code is the machine half of the refusal, so the row's message is + * the console's, naming the setting by the label the row draws. Every other + * code keeps the server's sentence. + * + * Driven with the shipped `en` pack's templates, so a key renamed in the pack + * turns this red rather than passing against a copy. + */ + +import { describe, it, expect } from 'vitest'; +import { en } from '@object-ui/i18n'; +import { fieldWording, invalidFormatMessage, settingsFieldRefusals } from '../settingsFieldRefusals'; +import type { Specifier } from '../types'; + +const VIEW = en.console.settingsView as Record; + +/** `t` over the `en` pack's `console.settingsView` templates. */ +function t(key: string, options: Record = {}): string { + const leaf = key.replace('console.settingsView.', ''); + const template = VIEW[leaf]; + if (typeof template !== 'string') throw new Error(`no en template for ${key}`); + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(options[name] ?? '')); +} + +const COUNTRY: Specifier = { + type: 'select', + key: 'default_country', + label: 'Default country', + pattern: '^[A-Za-z]{2}$', + valueDomain: 'iso_3166_alpha2', + options: [ + { value: 'US', label: 'United States (US)' }, + { value: 'CN', label: 'China (CN)' }, + ], +}; +const MODEL: Specifier = { + type: 'text', + key: 'gateway_model', + label: 'Gateway model', + description: 'Use provider/model.', + pattern: '^[a-z]+/[a-z]+$', +}; +const ACCOUNT: Specifier = { type: 'text', key: 'account_id', label: 'Account ID', pattern: '^[0-9a-f]+$' }; + +/** The 400 body's `error`, as `api.ts` parks it on `err.payload.error`. */ +function refusal(fields: Array<{ field: string; code: string; message: string }>) { + return { code: 'SETTINGS_VALIDATION', message: 'Settings are incomplete', details: { namespace: 'x', fields } }; +} + +const labels = { + fieldLabel: (_key: string, fallback: string) => fallback, + fieldHelp: (_key: string, fallback: string | undefined) => fallback, +}; + +describe('a format miss reads in the console words; every other refusal keeps the server sentence', () => { + it('a picker says to choose from the list, by the row label', () => { + expect(invalidFormatMessage(COUNTRY, { label: 'Default country' }, t)).toBe( + 'Choose a value for Default country from the list.', + ); + }); + + it('a typed field names the setting, and repeats the help line the refusal replaces', () => { + expect(invalidFormatMessage(MODEL, { label: 'Gateway model', help: 'Use provider/model.' }, t)).toBe( + 'Gateway model is not in a valid format. Use provider/model.', + ); + expect(invalidFormatMessage(ACCOUNT, { label: 'Account ID' }, t)).toBe('Account ID is not in a valid format.'); + expect(invalidFormatMessage(ACCOUNT, { label: 'Account ID', help: ' ' }, t)).toBe( + 'Account ID is not in a valid format.', + ); + }); + + it('maps each refused key in the order the server sent them, re-wording only invalid_format', () => { + const rows = settingsFieldRefusals( + refusal([ + { field: 'default_country', code: 'invalid_format', message: 'Default country does not match the expected format.' }, + { field: 'gateway_model', code: 'value_domain', message: 'Gateway model must be one of the listed models.' }, + ]), + [COUNTRY, MODEL], + fieldWording(labels), + t, + ); + expect(rows).toEqual([ + { field: 'default_country', message: 'Choose a value for Default country from the list.' }, + { field: 'gateway_model', message: 'Gateway model must be one of the listed models.' }, + ]); + }); + + it('names a key no specifier declares by the key itself', () => { + const rows = settingsFieldRefusals( + refusal([{ field: 'retired_key', code: 'invalid_format', message: 'Retired key does not match the expected format.' }]), + [COUNTRY], + fieldWording(labels), + t, + ); + expect(rows).toEqual([{ field: 'retired_key', message: 'retired_key is not in a valid format.' }]); + }); + + it('a refusal that is not field-scoped answers null, as before', () => { + expect(settingsFieldRefusals({ code: 'INTERNAL_ERROR', message: 'boom' }, [COUNTRY], fieldWording(labels), t)).toBeNull(); + expect(settingsFieldRefusals(undefined, [COUNTRY], fieldWording(labels), t)).toBeNull(); + }); +}); diff --git a/ui/apps/console/src/pages/settings/firstRunChecklistLogic.ts b/ui/apps/console/src/pages/settings/firstRunChecklistLogic.ts new file mode 100644 index 00000000000..fd924512ed0 --- /dev/null +++ b/ui/apps/console/src/pages/settings/firstRunChecklistLogic.ts @@ -0,0 +1,499 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * What the first-run checklist on Home shows an administrator, and when a row + * is done (objectstack#30206). The checklist replaces the dialog that asked for + * the workspace timezone and default currency on the first screens + * (objectui#11758, objectstack#28872); this module is that dialog's logic, + * widened to every row. + * + * ## What the dialog's rulings still decide + * + * The director's ruling on objectui#11693 (B2) put a gate on the served display + * zone: an administrator is asked, while `localization.timezone` is still the + * manifest default, whether to set it to the browser's zone. Confirm writes + * through the Settings page's own write path; decline writes nothing. + * ⛔ Not taken: writing the browser's zone silently on sign-in. The first + * administrator's browser would then decide the organization's clock with + * nobody told, for `TODAY()`, report buckets and rendered datetimes alike. + * + * objectstack#28872 added the currency: `localization.currency` has no platform + * default, and while no rung holds a value every code-less amount shows as a + * plain number and nobody is told. + * + * Both still hold here. The browser only PRE-FILLS a row's fields; nothing is + * written until the administrator presses the row's Confirm. A row that is not + * done stays on the checklist, so an unset currency does not go unnoticed. What + * changed is the form: a row on Home instead of a dialog at startup. + * + * ## Done is read from the real setting, never from a click + * + * Every row's `done` is computed from what the server answers: the resolved + * settings values, the organization's name, its members and invitations. The + * checklist keeps no record of which rows were "ticked". The one thing it does + * keep is whether this administrator HID it, on this device; see + * {@link dismissRecordKey}. + * + * ## One offer + * + * Language, country and currency are all read from the browser's first + * language (`browserLanguage`), so the three cannot contradict each other: the + * country is that language's region and the currency is that region's. The + * zone is the browser's own zone. + * + * Kept apart from `FirstRunChecklist.tsx` so that module exports a component + * only, and so every condition below is a pure function a test can drive + * without rendering. + * + * @module + */ + +import { currencyForLocale, isValueDomainMember } from '@objectstack/spec/shared'; +import { getSettingsNamespace, listSettingsManifests } from './api'; +import type { ResolvedSettingValue, SettingsManifest, Specifier } from './types'; + +/** The settings namespace that owns the workspace's regional defaults. */ +export const LOCALIZATION_NAMESPACE = 'localization'; + +/** The settings namespace that owns outbound mail delivery. */ +export const MAIL_NAMESPACE = 'mail'; + +/** The key the localization cascade resolves the workspace's zone from. */ +export const TIMEZONE_KEY = 'timezone'; + +/** The key a code-less amount takes its currency from. It has no platform default. */ +export const CURRENCY_KEY = 'currency'; + +/** The workspace's default language. */ +export const LOCALE_KEY = 'locale'; + +/** The workspace's default country, an ISO 3166-1 alpha-2 code. */ +export const COUNTRY_KEY = 'default_country'; + +/** + * The name plugin-auth gives the organization it creates for a deployment's + * first owner (`ensure-default-organization.ts`, and the same literal in + * `default-organization-invariant.ts`). Every invitee reads it until somebody + * renames the organization (objectstack#30125 row 1), so a name still equal to + * it is the one reading of "the company name was never set". + * ⚠️ A copy of the server's literal: no test reads plugin-auth's source from + * `ui/`, so a rename there must change this line too, or the row reads + * "named" for an organization nobody named. + */ +export const DEFAULT_ORGANIZATION_NAME = 'Default Organization'; + +/** The rows the checklist can show, in the order it shows them. */ +export type ChecklistRowId = + | 'organization_name' + | 'language_country' + | 'timezone' + | 'currency' + | 'mail' + | 'invite'; + +/** + * Where the "this administrator hid the checklist" record lives, per workspace + * and per user: `os:first-run-checklist::`. + * + * The device's `localStorage`, narrowed by the two ids, for the reason the + * dialog's "asked once" record lived there: the client holds no sign-in + * session identifier to key on, and this is the narrowest scope it can see + * that contains every tab of one sign-in. Another administrator, or the same + * administrator in another workspace, still sees the checklist. + * + * ⛔ Not a server-side "dismissed" flag: that would be a new settings key, and + * the dialog's ruling ruled out any new key. Hiding the checklist writes + * nothing to the server and changes no row's state. + */ +export const DISMISS_RECORD_PREFIX = 'os:first-run-checklist'; + +export function dismissRecordKey(orgId: string, userId: string): string { + return `${DISMISS_RECORD_PREFIX}:${orgId}:${userId}`; +} + +export type ChecklistStorage = Pick; + +/** The device's `localStorage`, or `undefined` where reading it throws (a private window). */ +export function deviceStorage(): ChecklistStorage | undefined { + try { + return typeof localStorage !== 'undefined' ? localStorage : undefined; + } catch { + return undefined; + } +} + +/** + * Did this administrator hide the checklist for this workspace on this device? + * + * Storage that cannot be read answers `false`: the checklist is part of the + * page, not a dialog in front of it, so showing it once too often costs a + * glance, while hiding it on an unreadable answer could leave an unset currency + * unnoticed. + */ +export function wasDismissed(storage: ChecklistStorage | undefined, key: string): boolean { + if (!storage) return false; + try { + return storage.getItem(key) !== null; + } catch { + return false; + } +} + +/** Record that the checklist was hidden. Returns `false` when it could not be written. */ +export function markDismissed(storage: ChecklistStorage | undefined, key: string): boolean { + if (!storage) return false; + try { + storage.setItem(key, new Date().toISOString()); + return true; + } catch { + return false; + } +} + +/** + * The browser's own zone, or `undefined` when it reports none. + * + * `resolvedOptions().timeZone` is `undefined` on engines that cannot tell, and + * ICU answers `Etc/Unknown` when the host zone is unknown; the second is not a + * zone (`new Intl.DateTimeFormat('en-US', { timeZone: 'Etc/Unknown' })` throws), + * which `isAdmissibleValue` refuses. This is the console's ONE reader of the + * browser's zone: nothing else in the console source reads it. + */ +export function browserTimeZone(): string | undefined { + try { + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return typeof zone === 'string' && zone.length > 0 ? zone : undefined; + } catch { + return undefined; + } +} + +/** + * The browser's preferred language, the first of `navigator.languages`, else + * `navigator.language`, or `undefined` when it reports none. + * + * Only the FIRST preference is read. A later one names a language the person + * also reads, not where they are: `es-419` then `es` would otherwise land on + * `es` → `ES` → EUR for someone in Latin America. + */ +export function browserLanguage(): string | undefined { + try { + if (typeof navigator === 'undefined') return undefined; + const first = Array.isArray(navigator.languages) ? navigator.languages[0] : undefined; + const tag = first || navigator.language; + return typeof tag === 'string' && tag.length > 0 ? tag : undefined; + } catch { + return undefined; + } +} + +/** + * The currency of the browser's region, or `undefined` when its language + * names no country region (`es-419`) or none at all. + * + * `currencyForLocale` from `@objectstack/spec/shared` decides: the tag's + * region, or for a bare language the likely one (`zh` → `CN`), through the + * CLDR table plugin-auth seeds a new organization's currency from. No guess is + * made beyond that table. + */ +export function browserCurrency(language: string | undefined = browserLanguage()): string | undefined { + return currencyForLocale(language); +} + +/** + * The country of the browser's language: its region, or for a bare language + * the likely one, read exactly as `currencyForLocale` reads it + * (`new Intl.Locale(tag).maximize().region`). That is what keeps the country + * and the currency one offer: `zh` → `CN` → CNY, `en-GB` → `GB` → GBP. + * + * A region that is not a country (`es-419`, `en-150`) answers `undefined`; + * so does a malformed tag. Membership in the country domain is the row's + * `isAdmissibleValue` check, not this function's. + */ +export function browserRegion(language: string | undefined = browserLanguage()): string | undefined { + if (typeof language !== 'string' || language.trim() === '') return undefined; + try { + const region = new Intl.Locale(language.trim()).maximize().region; + return typeof region === 'string' && /^[A-Z]{2}$/.test(region) ? region : undefined; + } catch { + return undefined; + } +} + +/** A tag's language and region subtags once maximized, or `undefined` for a malformed tag. */ +function subtagsOf(tag: string): { language: string; region?: string } | undefined { + try { + const locale = new Intl.Locale(tag).maximize(); + return { language: locale.language, region: locale.region }; + } catch { + return undefined; + } +} + +/** + * The `locale` option to offer for the browser's language, or `undefined`. + * + * `locale` declares no value domain, so its `options` are exhaustive + * (objectstack#5131) and the offer must be one of them. In order: the option + * that IS the browser's tag; else the option with the same language and + * region once both are maximized (`zh` → `zh-Hans-CN` matches `zh-CN`); else + * the one option with the same language (`en-GB` → `en-US`). Two options of + * that language and no region match: no offer, rather than a pick. + */ +export function localeOffer(spec: Specifier, language: string | undefined): string | undefined { + if (typeof language !== 'string' || language.trim() === '') return undefined; + const tag = language.trim(); + const values = (spec.options ?? []).map((option) => String(option.value)); + const exact = values.find((value) => value.toLowerCase() === tag.toLowerCase()); + if (exact) return exact; + const wanted = subtagsOf(tag); + if (!wanted) return undefined; + const sameLanguage = values.filter((value) => subtagsOf(value)?.language === wanted.language); + const sameRegion = sameLanguage.find((value) => subtagsOf(value)?.region === wanted.region); + if (sameRegion) return sameRegion; + return sameLanguage.length === 1 ? sameLanguage[0] : undefined; +} + +/** + * Would the settings write door admit `value` for this specifier? + * + * The same two rules the server applies to a `select` write, in the same + * order. A declared `valueDomain` is the enforcement boundary (objectstack#5712), + * judged by `isValueDomainMember` from `@objectstack/spec/shared`: the ONE + * membership predicate the settings door and the record write path share, so + * this is that predicate called, not a copy of it. Without a declared domain, + * the `options` table is exhaustive (objectstack#5131). + * + * The server judges with ITS runtime's data and this runs on the browser's, + * so a value this admits can still be refused at the door when the two + * disagree. The row then shows the refusal in the field's error slot, exactly + * where the Settings page shows it, and writes nothing. + */ +export function isAdmissibleValue(spec: Specifier, value: string): boolean { + if (spec.valueDomain) return isValueDomainMember(spec.valueDomain, value); + return (spec.options ?? []).some((option) => String(option.value) === value); +} + +/** + * Does the session hold the capability this manifest demands for a write? + * + * Fails CLOSED on every unknown, unlike `hasCapabilities`, which fails open on + * an unreported answer. Both directions are about who is SHOWN the checklist, + * never about who may write (the server enforces that on the PUT regardless): + * a row put to someone who cannot answer it is the worse failure, and a holder + * who is not shown it still has the Settings page. So: + * + * - `systemPermissions` not reported (`undefined`) → not shown; + * - a manifest that declares no `writePermission` → not shown. The spec's + * parse default is `setup.write`, the service's own fallback is the read + * permission, and this client does not pick between them. + */ +export function mayWrite( + manifest: Pick, + systemPermissions: readonly string[] | undefined, +): boolean { + const required = manifest.writePermission; + if (typeof required !== 'string' || required.length === 0) return false; + return Array.isArray(systemPermissions) && systemPermissions.includes(required); +} + +/** A resolved value that holds something: not `null`, `undefined` or blank. */ +function holdsValue(value: unknown): boolean { + return value !== null && value !== undefined && !(typeof value === 'string' && value.trim() === ''); +} + +/** + * Is this localization key set for the workspace? + * + * - `currency` has no platform default: it is set once any rung holds a value + * (a deployment whose manifest declares a default currency has one, and its + * amounts already carry the symbol). + * - `timezone`, `locale` and `default_country` carry platform defaults + * (`UTC`, `en-US`, `US`): they are set once a rung other than the manifest + * default holds them. The resolved value's `source` says which rung that is. + * The spec declares it the effective entry of the cascade ("The first entry + * where `value` is non-null is also the effective `source`"), so it says the + * same thing the Settings page's `cascadeChain` walk finds, from a member + * that is always present. + * + * A key locked by the environment resolves from `env`, so it counts as set: + * nobody on this page could change it. + */ +export function isKeySet(key: string, resolved: ResolvedSettingValue | undefined): boolean { + if (!resolved) return false; + if (key === CURRENCY_KEY) return holdsValue(resolved.value); + return resolved.source !== 'default' && holdsValue(resolved.value); +} + +/** One settings key a checklist row edits. */ +export interface ChecklistField { + key: string; + /** The key's specifier, rendered with the Settings page's own field. */ + spec: Specifier; + /** The key's resolved value, as the server answered it. */ + resolved: ResolvedSettingValue; + /** Whether the key is already set ({@link isKeySet}). */ + set: boolean; + /** What the browser suggests for an unset key, when the write door would take it. */ + offer?: string; +} + +/** A row that edits localization keys in place: language and country, timezone, currency. */ +export interface ChecklistSettingsRow { + id: 'language_country' | 'timezone' | 'currency'; + fields: ChecklistField[]; + /** Every field is set. */ + done: boolean; +} + +/** What the browser answers, each `undefined` when it cannot tell. */ +export interface BrowserAnswers { + language?: string; + zone?: string; +} + +/** The browser's answers, read once. */ +export function readBrowserAnswers(): BrowserAnswers { + return { language: browserLanguage(), zone: browserTimeZone() }; +} + +/** The keys each settings row edits, in the order its fields are drawn. */ +const SETTINGS_ROW_KEYS: ReadonlyArray<{ id: ChecklistSettingsRow['id']; keys: readonly string[] }> = [ + { id: 'language_country', keys: [LOCALE_KEY, COUNTRY_KEY] }, + { id: 'timezone', keys: [TIMEZONE_KEY] }, + { id: 'currency', keys: [CURRENCY_KEY] }, +]; + +/** What the browser would offer for `spec`, before the admissibility check. */ +function browserOfferFor(spec: Specifier, browser: BrowserAnswers): string | undefined { + switch (spec.key) { + case LOCALE_KEY: + return localeOffer(spec, browser.language); + case COUNTRY_KEY: + return browserRegion(browser.language); + case TIMEZONE_KEY: + return browser.zone; + case CURRENCY_KEY: + return browserCurrency(browser.language); + default: + return undefined; + } +} + +/** + * The localization rows, from the namespace's manifest and resolved values. + * + * A row is drawn only when every key it edits is declared by the manifest and + * resolved by the server: a deployment that retired a key gets no row that + * would write it. An unset field carries the browser's offer when the write + * door would take it; a set field carries none, and shows its current value. + */ +export function localizationRows( + manifest: SettingsManifest, + values: Record, + browser: BrowserAnswers, +): ChecklistSettingsRow[] { + const rows: ChecklistSettingsRow[] = []; + for (const { id, keys } of SETTINGS_ROW_KEYS) { + const fields: ChecklistField[] = []; + for (const key of keys) { + const spec = manifest.specifiers.find((s) => s.key === key); + const resolved = values[key]; + if (!spec || !resolved) break; + const set = isKeySet(key, resolved); + const candidate = set ? undefined : browserOfferFor(spec, browser); + const offer = candidate && isAdmissibleValue(spec, candidate) ? candidate : undefined; + fields.push({ key, spec, resolved, set, ...(offer ? { offer } : {}) }); + } + if (fields.length !== keys.length) continue; + rows.push({ id, fields, done: fields.every((field) => field.set) }); + } + return rows; +} + +/** + * Is outbound mail set up to deliver? + * + * The provider is one that delivers (`log` records and sends nothing, as its + * own label says), a From address is set, and an SMTP provider has a host. + * The server refuses a save that leaves a visible required field empty, so a + * saved API-key provider carries its key; the key is encrypted and reads back + * masked, which is why it is not judged here. + */ +export function isMailConfigured(values: Record): boolean { + const provider = values.provider?.value; + if (typeof provider !== 'string' || provider === '' || provider === 'log') return false; + if (!holdsValue(values.from_email?.value)) return false; + if (provider === 'smtp' && !holdsValue(values.smtp_host?.value)) return false; + return true; +} + +/** Has the organization been given a name of its own? */ +export function isOrganizationNamed(name: unknown): boolean { + if (typeof name !== 'string') return false; + const trimmed = name.trim(); + return trimmed !== '' && trimmed !== DEFAULT_ORGANIZATION_NAME; +} + +/** + * Has anyone besides the first administrator been asked in? More than one + * member, or an invitation still waiting to be accepted. + */ +export function hasInvitedColleagues( + members: readonly unknown[], + invitations: ReadonlyArray<{ status?: unknown }>, +): boolean { + return members.length > 1 || invitations.some((invitation) => invitation?.status === 'pending'); +} + +/** The settings half of the checklist: the localization namespace and, when the session may write it, mail. */ +export interface ChecklistSettingsState { + localization: { + manifest: SettingsManifest; + values: Record; + }; + /** `undefined` when this session may not write mail, or its read failed. */ + mail?: { done: boolean }; +} + +/** + * Read what the settings rows need, or `null` for "this session is not shown + * the checklist". + * + * The checklist is an administrator's: it is shown to a session that may write + * `localization`, the namespace most of its rows edit, and to nobody else, so + * a member never sees it. Reads through the Settings page's own client + * (`api.ts`), never a new endpoint. The list request comes first because it + * never refuses: it answers the manifests this session may READ, each + * carrying its `writePermission`, so a session that may not touch + * localization learns that without a `403` on the wire. Only a session that may + * write then reads the namespace, and mail only when the list names mail and + * the session may write it too. + */ +export async function readChecklistSettings( + systemPermissions: readonly string[] | undefined, +): Promise { + // No capability answer reported, or one that holds nothing: nothing below + // could pass `mayWrite`, so no request is spent finding that out. + if (!Array.isArray(systemPermissions) || systemPermissions.length === 0) return null; + + const { manifests } = await listSettingsManifests(); + const listed = manifests.find((m) => m.namespace === LOCALIZATION_NAMESPACE); + if (!listed || !mayWrite(listed, systemPermissions)) return null; + + const { manifest, values } = await getSettingsNamespace(LOCALIZATION_NAMESPACE); + if (!mayWrite(manifest, systemPermissions)) return null; + + const state: ChecklistSettingsState = { localization: { manifest, values } }; + const mailListed = manifests.find((m) => m.namespace === MAIL_NAMESPACE); + if (mailListed && mayWrite(mailListed, systemPermissions)) { + try { + const mail = await getSettingsNamespace(MAIL_NAMESPACE); + state.mail = { done: isMailConfigured(mail.values) }; + } catch { + // A mail read that fails leaves the row out: the checklist says nothing + // about a setting it could not read, rather than "not set". + } + } + return state; +} diff --git a/ui/apps/console/src/pages/settings/settingsFieldRefusals.ts b/ui/apps/console/src/pages/settings/settingsFieldRefusals.ts new file mode 100644 index 00000000000..b25730cc11c --- /dev/null +++ b/ui/apps/console/src/pages/settings/settingsFieldRefusals.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The per-field refusals of a settings save, as the rows under the inputs show + * them (objectstack#30206). + * + * The settings service refuses a value that misses its specifier's `pattern` + * with `code: 'invalid_format'` and a sentence written in English for every + * reader: `Default country does not match the expected format. …`. A Chinese + * administrator who typed 「中国」 into 默认国家 read exactly that. The code is + * the machine half of the refusal (ADR-0114 `FieldError`), so the console says + * it in its own words, in the reader's language, naming the setting by the + * label the page draws. The server's sentence ends with the specifier's + * description as a hint; the console's ends with the same description as the + * field draws it (`fieldHelp`), translated where a bundle carries it. Every + * other code keeps the server's sentence: it names the allowed set or the rule + * the server judged, which the console does not know better. + * + * `extractFieldErrors` reads each refusal's `field` and `message` and drops its + * `code`, so the code is read here from the same `details.fields` array the + * settings door declares. + * + * Shared by the Settings page and the first-run checklist on Home, which save + * through the same door. + * + * @module + */ + +import { extractFieldErrors } from '@object-ui/react'; +import { resolveLabel, type Specifier } from './types'; +import type { SettingsLabelHelpers } from './useSettingsLabel'; + +/** The machine code of a refusal for a value that misses its declared `pattern`. */ +export const INVALID_FORMAT_CODE = 'invalid_format'; + +type Translate = (key: string, options?: Record) => string; + +/** The `code` of each refused key, from the settings door's `details.fields`. */ +function refusalCodes(apiError: unknown): Map { + const codes = new Map(); + const details = (apiError as { details?: { fields?: unknown } } | null | undefined)?.details; + const fields = details && typeof details === 'object' ? details.fields : undefined; + if (!Array.isArray(fields)) return codes; + for (const entry of fields) { + if (!entry || typeof entry !== 'object') continue; + const { field, code } = entry as { field?: unknown; code?: unknown }; + if (typeof field === 'string' && typeof code === 'string') codes.set(field, code); + } + return codes; +} + +/** How the page names a field: its label, and the help line it draws under it. */ +export interface FieldWording { + label: string; + help?: string; +} + +/** + * The console's sentence for a value that misses its format: for a picker + * (a `select` that offers options) it says to pick from the list, which is how + * the value is entered; for any other field it names the setting and, when the + * field has a help line, repeats it, because the refusal takes the help line's + * place under the input. + */ +export function invalidFormatMessage(spec: Specifier | undefined, field: FieldWording, t: Translate): string { + const picker = spec?.type === 'select' && (spec.options?.length ?? 0) > 0; + if (picker) return t('console.settingsView.invalidFormatPick', { setting: field.label }); + const help = field.help?.trim(); + return help + ? t('console.settingsView.invalidFormatHelp', { setting: field.label, help }) + : t('console.settingsView.invalidFormat', { setting: field.label }); +} + +/** + * The refused keys of a failed save with the message each row shows, in the + * order the server sent them, or `null` when the refusal was not field-scoped. + */ +export function settingsFieldRefusals( + apiError: unknown, + specifiers: ReadonlyArray, + wordingOf: (key: string, spec: Specifier | undefined) => FieldWording, + t: Translate, +): Array<{ field: string; message: string }> | null { + const perField = extractFieldErrors(apiError); + if (!perField?.length) return null; + const codes = refusalCodes(apiError); + return perField.map(({ field, message }) => { + if (codes.get(field) !== INVALID_FORMAT_CODE) return { field, message }; + const spec = specifiers.find((s) => s.key === field); + return { field, message: invalidFormatMessage(spec, wordingOf(field, spec), t) }; + }); +} + +/** + * A field's wording as `SettingsField` draws it: the label and help line + * through the namespace's translation helpers, falling back to the manifest's + * literals, and the key itself for a key no specifier declares. + */ +export function fieldWording( + labels: Pick, +): (key: string, spec: Specifier | undefined) => FieldWording { + return (key, spec) => ({ + label: labels.fieldLabel(key, spec ? resolveLabel(spec.label) : key), + help: spec ? labels.fieldHelp(key, spec.description) : undefined, + }); +} diff --git a/ui/apps/console/src/pages/settings/workspaceTimezonePromptLogic.ts b/ui/apps/console/src/pages/settings/workspaceTimezonePromptLogic.ts deleted file mode 100644 index bea81a1b821..00000000000 --- a/ui/apps/console/src/pages/settings/workspaceTimezonePromptLogic.ts +++ /dev/null @@ -1,325 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * When the console asks an administrator to set a still-default workspace - * timezone or a still-unset default currency, and what it offers - * (objectui#11758; the currency arm, objectstack#28872). - * - * The director's ruling on objectui#11693 (B2) gates the served display zone on - * this prompt: before the console renders every instant in the workspace's - * `localization.timezone`, an administrator is asked ONCE, while that key is - * still the manifest default, whether to set it to the browser's zone. Confirm - * writes through the Settings page's own write path; decline writes nothing. - * ⛔ Not taken: writing the browser's zone silently on sign-in. The first - * administrator's browser would then decide the organization's clock with - * nobody told, for `TODAY()`, report buckets and rendered datetimes alike. - * - * The currency arm asks the same way about `localization.currency`, which has - * no platform default: while no rung holds a value, every code-less amount - * shows as a plain number and nobody is told. The offer is the currency of the - * browser's region, read through `currencyForLocale` from - * `@objectstack/spec/shared`, the CLDR table plugin-auth gives a new - * organization its currency from; the console carries no copy of it. When - * both keys are unset it is ONE prompt with both fields, never two dialogs, - * and one record says this administrator was asked. - * - * Kept apart from `WorkspaceTimezonePrompt.tsx` so that module exports a - * component only, and so every condition below is a pure function a test can - * drive without rendering. - * - * @module - */ - -import { currencyForLocale, isValueDomainMember } from '@objectstack/spec/shared'; -import { getSettingsNamespace, listSettingsManifests } from './api'; -import type { ResolvedSettingValue, SettingsManifest, Specifier } from './types'; - -/** The settings namespace that owns the workspace's regional defaults. */ -export const LOCALIZATION_NAMESPACE = 'localization'; - -/** The key the localization cascade resolves the workspace's zone from. */ -export const TIMEZONE_KEY = 'timezone'; - -/** The key a code-less amount takes its currency from. It has no platform default. */ -export const CURRENCY_KEY = 'currency'; - -/** - * Where the "this administrator was asked" record lives, per workspace and per - * user: `os:workspace-timezone-prompt::`. - * - * Why the device's `localStorage` and not `sessionStorage`. The card says a - * declining administrator's SESSION is not asked again, and the session meant - * there is the administrator's sign-in session. `sessionStorage` is per TAB, so - * a second tab of the same sign-in would ask again. The client holds no - * sign-in-session identifier to key on: `AuthClientSession` carries the bearer - * token, and an SSO re-entry can mint a new one within one sitting (the same - * measurement `recoveryReminderGate.ts` in `@object-ui/app-shell` records). The - * narrowest scope this client can see that contains every tab of one sign-in - * is therefore the device, narrowed by the two ids: another administrator, or - * the same administrator in another workspace, is still asked. - * - * ⛔ Not a server-side "dismissed" flag: that would be a new settings key, and - * the card rules out any new key. - * - * The currency arm shares this record: one prompt, one "asked". An - * administrator already asked about the timezone is therefore not asked - * about the currency. - */ -export const PROMPT_RECORD_PREFIX = 'os:workspace-timezone-prompt'; - -export function promptRecordKey(orgId: string, userId: string): string { - return `${PROMPT_RECORD_PREFIX}:${orgId}:${userId}`; -} - -export type PromptStorage = Pick; - -/** The device's `localStorage`, or `undefined` where reading it throws (a private window). */ -export function deviceStorage(): PromptStorage | undefined { - try { - return typeof localStorage !== 'undefined' ? localStorage : undefined; - } catch { - return undefined; - } -} - -/** - * Was this administrator already asked about this workspace on this device? - * - * Storage that throws answers `true`: a prompt that cannot record that it was - * shown cannot keep its "once", so when it cannot tell, it does not ask. - */ -export function wasAsked(storage: PromptStorage, key: string): boolean { - try { - return storage.getItem(key) !== null; - } catch { - return true; - } -} - -/** - * Record that the prompt was SHOWN. Written when the prompt opens, not when it - * is answered: closing the tab, navigating away or reloading with the prompt - * open is not a reason to ask a second time. Returns `false` when the record - * could not be written, and the caller then does not show the prompt. - */ -export function markAsked(storage: PromptStorage, key: string): boolean { - try { - storage.setItem(key, new Date().toISOString()); - return true; - } catch { - return false; - } -} - -/** - * The browser's own zone, or `undefined` when it reports none. - * - * `resolvedOptions().timeZone` is `undefined` on engines that cannot tell, and - * ICU answers `Etc/Unknown` when the host zone is unknown; the second is not a - * zone (`new Intl.DateTimeFormat('en-US', { timeZone: 'Etc/Unknown' })` throws), - * which `isAdmissibleZone` refuses. This is the console's ONE reader of the - * browser's zone: nothing else in the console source reads it. - */ -export function browserTimeZone(): string | undefined { - try { - const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; - return typeof zone === 'string' && zone.length > 0 ? zone : undefined; - } catch { - return undefined; - } -} - -/** - * The browser's preferred language, the first of `navigator.languages`, else - * `navigator.language`, or `undefined` when it reports none. - * - * Only the FIRST preference is read. A later one names a language the person - * also reads, not where they are: `es-419` then `es` would otherwise land on - * `es` → `ES` → EUR for someone in Latin America. - */ -export function browserLanguage(): string | undefined { - try { - if (typeof navigator === 'undefined') return undefined; - const first = Array.isArray(navigator.languages) ? navigator.languages[0] : undefined; - const tag = first || navigator.language; - return typeof tag === 'string' && tag.length > 0 ? tag : undefined; - } catch { - return undefined; - } -} - -/** - * The currency of the browser's region, or `undefined` when its language - * names no country region (`es-419`) or none at all. - * - * `currencyForLocale` from `@objectstack/spec/shared` decides: the tag's - * region, or for a bare language the likely one (`zh` → `CN`), through the - * CLDR table plugin-auth seeds a new organization's currency from. No guess is - * made beyond that table. - */ -export function browserCurrency(): string | undefined { - return currencyForLocale(browserLanguage()); -} - -/** - * Would the settings write door admit `value` for this specifier? - * - * The same two rules the server applies to a `select` write, in the same - * order. A declared `valueDomain` is the enforcement boundary (objectstack#5712), - * judged by `isValueDomainMember` from `@objectstack/spec/shared`: the ONE - * membership predicate the settings door and the record write path share, so - * this is that predicate called, not a copy of it. Without a declared domain, - * the `options` table is exhaustive (objectstack#5131). - * - * The server judges with ITS runtime's data and this runs on the browser's, - * so a value this admits can still be refused at the door when the two - * disagree. The prompt then shows the refusal in the field's error slot, - * exactly where the Settings page shows it, and writes nothing. - */ -export function isAdmissibleValue(spec: Specifier, value: string): boolean { - if (spec.valueDomain) return isValueDomainMember(spec.valueDomain, value); - return (spec.options ?? []).some((option) => String(option.value) === value); -} - -/** {@link isAdmissibleValue} for the `timezone` specifier. */ -export function isAdmissibleZone(spec: Specifier, zone: string): boolean { - return isAdmissibleValue(spec, zone); -} - -/** - * Does the session hold the capability this manifest demands for a write? - * - * Fails CLOSED on every unknown, unlike `hasCapabilities`, which fails open on - * an unreported answer. Both directions are about who gets ASKED, never about - * who may write (the server enforces that on the PUT regardless): an - * unprompted question put to someone who cannot answer it is the worse - * failure, and a holder who is not asked still has the Settings page. So: - * - * - `systemPermissions` not reported (`undefined`) → not asked; - * - a manifest that declares no `writePermission` → not asked. The spec's - * parse default is `setup.write`, the service's own fallback is the read - * permission, and this client does not pick between them. - */ -export function mayWrite( - manifest: Pick, - systemPermissions: readonly string[] | undefined, -): boolean { - const required = manifest.writePermission; - if (typeof required !== 'string' || required.length === 0) return false; - return Array.isArray(systemPermissions) && systemPermissions.includes(required); -} - -/** The timezone arm: the zone, and the field it is edited in. */ -export interface TimezoneOffer { - /** The `timezone` specifier, rendered with the Settings page's own field. */ - spec: Specifier; - /** The key's resolved value: `source: 'default'`, unlocked. */ - resolved: ResolvedSettingValue; - /** The workspace's current zone, the manifest default. */ - current: string; - /** The browser's zone, the pre-filled answer. */ - zone: string; -} - -/** The currency arm: the currency, and the field it is edited in. */ -export interface CurrencyOffer { - /** The `currency` specifier, rendered with the Settings page's own field. */ - spec: Specifier; - /** The key's resolved value: `source: 'default'`, no value, unlocked. */ - resolved: ResolvedSettingValue; - /** The browser region's currency, the pre-filled answer. */ - currency: string; -} - -/** What the one prompt offers: either arm, or both. Never neither. */ -export interface WorkspacePromptOffer { - timezone?: TimezoneOffer; - currency?: CurrencyOffer; -} - -/** What the browser answers, each `undefined` when it cannot tell. */ -export interface BrowserAnswers { - zone?: string; - currency?: string; -} - -/** - * Which keys to ask about. Answers the offer, or `null` for "do not ask". - * - * - **timezone**: `localization.timezone` is still the manifest default, and - * the browser has a zone the door would take. - * - **currency**: `localization.currency` resolves from the default with no - * value at all (the key declares no default, so no rung holds one), and the - * browser's region has a currency the door would take. - * - * Either needs a session that may write the namespace. Reads through the - * Settings page's own client (`api.ts`), never a new endpoint, and once for - * both arms. The list request comes first because it never refuses: it - * answers the manifests this session may READ, each carrying its - * `writePermission`, so a session that may not touch localization learns that - * without a `403` on the wire. Only a session that may write then reads the - * namespace. - * - * "Still the default" is the resolved value's `source`. The spec declares it - * the effective entry of the cascade ("The first entry where `value` is - * non-null is also the effective `source`"), and the service sets it from that - * entry, so it says the same thing the Settings page's `cascadeChain` walk - * finds, from a member that is always present: `cascadeChain` is optional on - * the wire. A locked key is not offered either: the write would be refused. - */ -export async function findWorkspacePromptOffer( - systemPermissions: readonly string[] | undefined, - browser: BrowserAnswers, -): Promise { - // No capability answer reported, or one that holds nothing: nothing below - // could pass `mayWrite`, so no request is spent finding that out. Nor when - // the browser has nothing to offer for either key. - if (!Array.isArray(systemPermissions) || systemPermissions.length === 0) return null; - if (!browser.zone && !browser.currency) return null; - - const { manifests } = await listSettingsManifests(); - const listed = manifests.find((m) => m.namespace === LOCALIZATION_NAMESPACE); - if (!listed || !mayWrite(listed, systemPermissions)) return null; - - const { manifest, values } = await getSettingsNamespace(LOCALIZATION_NAMESPACE); - if (!mayWrite(manifest, systemPermissions)) return null; - - const offer: WorkspacePromptOffer = {}; - const timezone = browser.zone ? timezoneArm(manifest, values, browser.zone) : null; - if (timezone) offer.timezone = timezone; - const currency = browser.currency ? currencyArm(manifest, values, browser.currency) : null; - if (currency) offer.currency = currency; - return offer.timezone || offer.currency ? offer : null; -} - -function timezoneArm( - manifest: SettingsManifest, - values: Record, - zone: string, -): TimezoneOffer | null { - const resolved = values[TIMEZONE_KEY]; - if (!resolved || resolved.source !== 'default' || resolved.locked) return null; - if (typeof resolved.value !== 'string') return null; - - const spec = manifest.specifiers.find((s) => s.key === TIMEZONE_KEY); - if (!spec || !isAdmissibleZone(spec, zone)) return null; - - return { spec, resolved, current: resolved.value, zone }; -} - -function currencyArm( - manifest: SettingsManifest, - values: Record, - currency: string, -): CurrencyOffer | null { - const resolved = values[CURRENCY_KEY]; - if (!resolved || resolved.source !== 'default' || resolved.locked) return null; - // A deployment whose manifest declares a default currency has one: amounts - // already carry its symbol, so there is nothing to ask. - if (resolved.value !== null && resolved.value !== undefined && resolved.value !== '') return null; - - const spec = manifest.specifiers.find((s) => s.key === CURRENCY_KEY); - if (!spec || !isAdmissibleValue(spec, currency)) return null; - - return { spec, resolved, currency }; -} diff --git a/ui/e2e/live/global-setup.ts b/ui/e2e/live/global-setup.ts index 8b790cb0b14..89a1b3d38d2 100644 --- a/ui/e2e/live/global-setup.ts +++ b/ui/e2e/live/global-setup.ts @@ -1,4 +1,4 @@ -import { chromium, request, type FullConfig, type LaunchOptions } from '@playwright/test'; +import { request } from '@playwright/test'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; @@ -31,7 +31,7 @@ const REPO_ROOT = decodeURIComponent(new URL(import.meta.url).pathname) .join('/'); const STATE_PATH = join(REPO_ROOT, 'e2e/live/.auth/state.json'); -export default async function globalSetup(config: FullConfig) { +export default async function globalSetup() { const ctx = await request.newContext(); let res; try { @@ -62,89 +62,7 @@ export default async function globalSetup(config: FullConfig) { writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); // eslint-disable-next-line no-console console.log(`[live-e2e] authenticated as ${EMAIL}; storageState written to ${STATE_PATH}`); - - // The specs' own launch options, so the prompt is answered in the browser - // the specs run in. - await answerTimezonePrompt(token, config.projects[0]?.use?.launchOptions); -} - -/** - * Answer the console's one-time workspace prompt before any spec runs - * (objectui#11758; its currency arm, objectstack#28872). - * - * On a `--fresh` backend `localization.timezone` is still the manifest default, - * or `localization.currency` has no value, and the seeded admin may write - * settings, so the console opens ONE modal prompt (both fields when both are - * unset) on the first app it renders, and every spec would meet it in front of - * the control it means to click. The run answers it ONCE, in a real browser, - * through the prompt's own buttons, and keeps the outcome in the storageState - * the specs reuse: - * - * LIVE_TIMEZONE_PROMPT=decline (the default) the prompt's decline button - * ("Keep the default" / "Not now"): nothing is - * written; the prompt records that it was - * shown in the app origin's localStorage, and - * the storageState carries that record. - * LIVE_TIMEZONE_PROMPT=confirm the prompt's confirm button: what it offers - * (the browser's zone, the browser region's - * currency) is written to the workspace - * through the Settings write path, so no later - * page is asked either. - * - * Skipped when the backend says the prompt would not ask: the console's own - * condition per key (`workspaceTimezonePromptLogic.ts`), a zone still the - * default and unlocked, or a currency resolving from the default with no - * value and unlocked. A prompt that was expected but never shown is - * reported, not failed: the specs then show whether anything stands in their - * way. - */ -async function answerTimezonePrompt(token: string, launchOptions: LaunchOptions | undefined) { - const mode = process.env.LIVE_TIMEZONE_PROMPT === 'confirm' ? 'confirm' : 'decline'; - const api = await request.newContext({ extraHTTPHeaders: { Authorization: `Bearer ${token}` } }); - let wouldAsk = false; - try { - const res = await api.get(`${API}/api/settings/localization`); - if (res.ok()) { - const body = await res.json(); - const values = (body?.data ?? body)?.values; - const timezone = values?.timezone; - const currency = values?.currency; - const timezoneUnset = timezone?.source === 'default' && !timezone?.locked; - const currencyUnset = - currency?.source === 'default' && - !currency?.locked && - (currency?.value === null || currency?.value === undefined || currency?.value === ''); - wouldAsk = timezoneUnset || currencyUnset; - } - } finally { - await api.dispose(); - } - if (!wouldAsk) { - // eslint-disable-next-line no-console - console.log('[live-e2e] workspace timezone and currency already chosen; no prompt to answer'); - return; - } - - const browser = await chromium.launch(launchOptions); - try { - const context = await browser.newContext({ storageState: STATE_PATH, baseURL: APP }); - const page = await context.newPage(); - // The built-in Setup app: present on every backend, and an app is where - // the console mounts the prompt. - await page.goto('/apps/setup'); - const prompt = page.getByTestId('workspace-timezone-prompt'); - try { - await prompt.waitFor({ state: 'visible', timeout: 60_000 }); - } catch { - console.warn('[live-e2e] the workspace timezone or currency is unset, but the console showed no prompt'); - return; - } - await page.getByTestId(`workspace-timezone-prompt-${mode}`).click(); - await prompt.waitFor({ state: 'hidden' }); - await context.storageState({ path: STATE_PATH }); - // eslint-disable-next-line no-console - console.log(`[live-e2e] workspace prompt answered: ${mode}`); - } finally { - await browser.close(); - } + // No first-run answer is needed before the specs: the administrator's + // first-run checklist (objectstack#30206) is a card on Home, not a dialog + // over the page, and it writes nothing until its own Confirm. } diff --git a/ui/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts b/ui/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts index 16fd301d8da..3bc7d8be295 100644 --- a/ui/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts +++ b/ui/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts @@ -23,7 +23,7 @@ import { builtInLocales } from '../locales'; const FAMILIES = [ 'console.settingsHub', - 'console.workspaceTimezonePrompt', + 'console.firstRunChecklist', 'console.readRate', 'auth', 'profile.password', diff --git a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 9ac721c77f0..70046bf958a 100644 --- a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -393,7 +393,12 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // `marketplace.detail.sampleDataBannerTitle`, the banner that names the // installed app holding sample data („{{name}}“) — one interpolated span, // runtime data. - expect(okSpans, 'correctly paired spans').toBe(105); + // 107 once objectstack-ai/objectstack#30206 added the first-run checklist: + // `console.firstRunChecklist.organizationName.todo` quotes the name the + // organization still carries („{{name}}“) — one interpolated span, runtime + // data — and `.timezone.todo` quotes the word formulas read („heute“) — + // one literal span. + expect(okSpans, 'correctly paired spans').toBe(107); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -479,7 +484,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // `console.organizationMfa.needsTwoFactor`, one more matched pair. // 105 / 105 / 0 after objectstack-ai/objectstack#30323 added // `marketplace.detail.sampleDataBannerTitle`, one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 105, close: 105, rdq: 0 }); + // 107 / 107 / 0 after objectstack-ai/objectstack#30206 added + // `console.firstRunChecklist.organizationName.todo` and `.timezone.todo`, + // one matched pair each. + expect({ open, close, rdq }).toEqual({ open: 107, close: 107, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/ui/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts b/ui/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts index e7f8a3176d1..82fa3552d5e 100644 --- a/ui/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts +++ b/ui/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts @@ -256,13 +256,14 @@ const DECLARED: Exemption[] = [ 'formatted and no face is rendered.', }, { - file: 'apps/console/src/pages/settings/workspaceTimezonePromptLogic.ts', + file: 'apps/console/src/pages/settings/firstRunChecklistLogic.ts', expression: 'const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;', count: 1, verdict: 'not applicable', reason: - 'reads the HOST time zone for the workspace-timezone prompt (objectui#11758), the one member of ' + - '`resolvedOptions()` the locale does not decide; nothing is formatted and no face is rendered.', + 'reads the HOST time zone the first-run checklist offers for the system timezone (objectstack#30206, ' + + 'formerly the workspace-timezone prompt, objectui#11758), the one member of `resolvedOptions()` the ' + + 'locale does not decide; nothing is formatted and no face is rendered.', }, { file: 'packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx', diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 3d9049524c7..c4308bf5c9c 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2447,22 +2447,53 @@ const ar = { clientAddressDirect: "عنوانك كما يحدده الخادم: {{address}}. لا يمكن حفظ قائمة لا تتضمنه من هنا. لا يثق هذا الخادم بترويسات الوكيل، لذا خلف وكيل يكون هذا عنوان الوكيل.", clientAddressViaProxy: "عنوانك كما يحدده الخادم: {{address}}، مقروءًا من ترويسات التمرير للوكيل الذي يثق به هذا الخادم. لا يمكن حفظ قائمة لا تتضمنه من هنا.", clientAddressUnknown: "لا يستطيع الخادم تحديد عنوانك، لذا لا يمكن حفظ قائمة غير فارغة من هنا.", - }, - workspaceTimezonePrompt: { - title: "تعيين المنطقة الزمنية للنظام", - description: "لا يزال هذا النظام يستخدم المنطقة الزمنية الافتراضية للمنصة، {{current}}. تحدد المنطقة الزمنية للنظام معنى \"اليوم\" في الصيغ، وكيفية تجميع التقارير حسب التاريخ، وكيفية عرض التواريخ والأوقات، لجميع مستخدمي النظام. تم إدخال المنطقة الزمنية لمتصفحك أدناه.", - laterHint: "يمكنك تغييرها في أي وقت من صفحة الإعدادات.", - decline: "الإبقاء على الافتراضي", - confirm: "تعيين المنطقة الزمنية", - saved: "تم تعيين المنطقة الزمنية للنظام إلى {{zone}}", - currencyTitle: "تعيين العملة الافتراضية", - currencyDescription: "لم يتم تعيين عملة افتراضية لهذه المؤسسة بعد، لذلك تُعرض المبالغ بدون رمز عملة. تم ملء عملة منطقة متصفحك أدناه: {{name}} ({{code}}).", - currencyConfirm: "تعيين العملة", - currencySaved: "تم تعيين العملة الافتراضية إلى {{currency}}", - bothTitle: "تعيين المنطقة الزمنية للنظام والعملة الافتراضية", - bothConfirm: "حفظ", - bothSaved: "تم تعيين المنطقة الزمنية للنظام إلى {{zone}} والعملة الافتراضية إلى {{currency}}", - notNow: "ليس الآن", + invalidFormat: "تنسيق {{setting}} غير صالح.", + invalidFormatHelp: "تنسيق {{setting}} غير صالح. {{help}}", + invalidFormatPick: "اختر قيمة {{setting}} من القائمة.", + }, + firstRunChecklist: { + title: "جهّز نظامك", + description: "ما ينبغي إعداده قبل أن يبدأ زملاؤك. يمكن تغيير كل عنصر لاحقًا في Setup.", + progress: "اكتمل {{done}} من {{total}}", + dismiss: "إخفاء القائمة", + set: "تعيين", + change: "تغيير", + view: "عرض", + confirm: "تأكيد", + cancel: "إلغاء", + prefilled: "القيم التي لم تُعيَّن بعد مقترحة من متصفحك. لن يُحفظ شيء حتى تؤكد.", + organizationName: { + title: "سمِّ مؤسستك", + todo: "لا تزال تحمل الاسم الذي أُنشئت به: \"{{name}}\".", + action: "إعادة التسمية", + }, + languageCountry: { + title: "اللغة والبلد الافتراضيان", + todo: "حاليًا {{language}} و{{country}}. أكّدهما أو اختر غيرهما.", + saved: "تم حفظ اللغة والبلد الافتراضيين", + }, + timezone: { + title: "المنطقة الزمنية للنظام", + todo: "لا تزال المنطقة الزمنية الافتراضية للمنصة، {{current}}. وهي تحدد معنى \"اليوم\" في الصيغ وكيفية تجميع التقارير للتواريخ.", + saved: "تم تعيين المنطقة الزمنية للنظام إلى {{zone}}", + }, + currency: { + title: "العملة الافتراضية", + todo: "لم تُعيَّن بعد، لذا تظهر المبالغ دون رمز عملة.", + saved: "تم تعيين العملة الافتراضية إلى {{currency}}", + }, + mail: { + title: "إرسال البريد الإلكتروني", + todo: "لم يُعدّ بعد، لذا لا تُسلَّم رسائل مثل الدعوات وإعادة تعيين كلمة المرور.", + done: "تم الإعداد لتسليم رسائل البريد الإلكتروني.", + action: "إعداد", + }, + invite: { + title: "ادعُ زملاءك", + todo: "أنت العضو الوحيد حتى الآن.", + done: "انضم زملاء أو تمت دعوتهم.", + action: "دعوة", + }, }, error: { connectionFailed: "تعذّر الاتصال بالخادم", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index b443dce0fa1..d5e43dfdabe 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2208,22 +2208,53 @@ const de = { clientAddressDirect: "Ihre Adresse, wie der Server sie beurteilt: {{address}}. Eine Liste ohne diese Adresse kann von hier aus nicht gespeichert werden. Dieser Server vertraut keinen Proxy-Headern, hinter einem Proxy ist dies also die Adresse des Proxys.", clientAddressViaProxy: "Ihre Adresse, wie der Server sie beurteilt: {{address}}, gelesen aus den Weiterleitungs-Headern des Proxys, dem dieser Server vertraut. Eine Liste ohne diese Adresse kann von hier aus nicht gespeichert werden.", clientAddressUnknown: "Der Server kann Ihre Adresse nicht ermitteln, daher kann von hier aus keine nicht leere Liste gespeichert werden.", - }, - workspaceTimezonePrompt: { - title: "Zeitzone des Systems festlegen", - description: "Dieses System verwendet noch die Standardzeitzone der Plattform, {{current}}. Die Zeitzone des Systems bestimmt, welcher Tag in Formeln als heute gilt, wie Berichte nach Datum gruppieren und wie Datum und Uhrzeit angezeigt werden – für alle im System. Unten ist die Zeitzone Ihres Browsers eingetragen.", - laterHint: "Sie können sie jederzeit in den Einstellungen ändern.", - decline: "Standard beibehalten", - confirm: "Zeitzone festlegen", - saved: "Zeitzone des Systems auf {{zone}} festgelegt", - currencyTitle: "Standardwährung festlegen", - currencyDescription: "Für diese Organisation ist noch keine Standardwährung festgelegt, daher werden Beträge ohne Währungssymbol angezeigt. Unten ist die Währung der Region Ihres Browsers eingetragen: {{name}} ({{code}}).", - currencyConfirm: "Währung festlegen", - currencySaved: "Standardwährung auf {{currency}} festgelegt", - bothTitle: "Zeitzone und Standardwährung des Systems festlegen", - bothConfirm: "Speichern", - bothSaved: "Zeitzone des Systems auf {{zone}} und Standardwährung auf {{currency}} festgelegt", - notNow: "Später", + invalidFormat: "{{setting}} hat kein gültiges Format.", + invalidFormatHelp: "{{setting}} hat kein gültiges Format. {{help}}", + invalidFormatPick: "Wählen Sie einen Wert für {{setting}} aus der Liste.", + }, + firstRunChecklist: { + title: "System einrichten", + description: "Was Sie einrichten sollten, bevor Ihre Kollegen loslegen. Jeder Punkt lässt sich später in Setup ändern.", + progress: "{{done}} von {{total}} erledigt", + dismiss: "Checkliste ausblenden", + set: "Festlegen", + change: "Ändern", + view: "Anzeigen", + confirm: "Bestätigen", + cancel: "Abbrechen", + prefilled: "Noch nicht festgelegte Werte sind aus Ihrem Browser vorgeschlagen. Gespeichert wird erst, wenn Sie bestätigen.", + organizationName: { + title: "Organisation benennen", + todo: "Sie trägt noch den Namen, mit dem sie angelegt wurde: „{{name}}“.", + action: "Umbenennen", + }, + languageCountry: { + title: "Standardsprache und -land", + todo: "Derzeit {{language}} und {{country}}. Bestätigen Sie diese oder wählen Sie andere.", + saved: "Standardsprache und -land gespeichert", + }, + timezone: { + title: "Systemzeitzone", + todo: "Noch die Standardzeitzone der Plattform, {{current}}. Sie bestimmt, was „heute“ in Formeln bedeutet und wie Berichte Daten gruppieren.", + saved: "Systemzeitzone auf {{zone}} festgelegt", + }, + currency: { + title: "Standardwährung", + todo: "Noch nicht festgelegt, daher werden Beträge ohne Währungssymbol angezeigt.", + saved: "Standardwährung auf {{currency}} festgelegt", + }, + mail: { + title: "E-Mail-Versand", + todo: "Noch nicht eingerichtet, daher werden E-Mails wie Einladungen und Passwort-Zurücksetzungen nicht zugestellt.", + done: "Für den E-Mail-Versand eingerichtet.", + action: "Einrichten", + }, + invite: { + title: "Kollegen einladen", + todo: "Bisher sind Sie das einzige Mitglied.", + done: "Kollegen sind beigetreten oder wurden eingeladen.", + action: "Einladen", + }, }, error: { connectionFailed: "Verbindung zum Server nicht möglich", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index a6dfa62dc0d..798e9ac9293 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2369,25 +2369,64 @@ const en = { clientAddressDirect: 'Your address, as the server judges it: {{address}}. A list that leaves it out cannot be saved from here. This server does not trust proxy headers, so behind a proxy this is the proxy\'s address.', clientAddressViaProxy: 'Your address, as the server judges it: {{address}}, read from the forwarded headers of the proxy this server trusts. A list that leaves it out cannot be saved from here.', clientAddressUnknown: 'The server cannot establish your address, so a non-empty list cannot be saved from here.', - }, - // The one-time workspace-timezone prompt (objectui#11758): asked of an - // administrator while `localization.timezone` is still the manifest default. - // `{{current}}` is that default, `{{zone}}` the zone the admin confirmed. - workspaceTimezonePrompt: { - title: 'Set the system timezone', - description: 'This system still uses the platform default timezone, {{current}}. The system timezone decides what "today" means in formulas, how reports group dates and how dates and times are shown, for everyone in the system. Your browser timezone is filled in below.', - laterHint: 'You can change it at any time on the Settings page.', - decline: 'Keep the default', - confirm: 'Set timezone', - saved: 'System timezone set to {{zone}}', - currencyTitle: 'Set the default currency', - currencyDescription: 'This organization has no default currency yet, so amounts are shown without a currency symbol. The currency of your browser\'s region, {{name}} ({{code}}), is filled in below.', - currencyConfirm: 'Set currency', - currencySaved: 'Default currency set to {{currency}}', - bothTitle: 'Set the system timezone and default currency', - bothConfirm: 'Save', - bothSaved: 'System timezone set to {{zone}} and default currency set to {{currency}}', - notNow: 'Not now', + // A settings value that misses its declared pattern (objectstack#30206), + // said in the console's words instead of the server's English sentence. + // `{{setting}}` is the setting's label and `{{help}}` the help line the + // refusal replaces under the field; `invalidFormatPick` is for a setting + // chosen from a list. + invalidFormat: '{{setting}} is not in a valid format.', + invalidFormatHelp: '{{setting}} is not in a valid format. {{help}}', + invalidFormatPick: 'Choose a value for {{setting}} from the list.', + }, + // The first-run checklist at the top of Home (objectstack#30206): what an + // administrator sets up first, each row's line read from the real setting. + // `{{name}}` is the organization's name; `{{language}}`, `{{country}}`, + // `{{zone}}` and `{{currency}}` the values in words; `{{current}}` the + // timezone the system uses now. `progress` counts the rows shown. A row + // that is done shows its value alone, so it has no `done` line here. + firstRunChecklist: { + title: 'Get your system ready', + description: 'What to set up before your colleagues start. Every item can be changed later in Setup.', + progress: '{{done}} of {{total}} done', + dismiss: 'Hide checklist', + set: 'Set', + change: 'Change', + view: 'View', + confirm: 'Confirm', + cancel: 'Cancel', + prefilled: 'Values not set yet are suggested from your browser. Nothing is saved until you confirm.', + organizationName: { + title: 'Name your organization', + todo: 'It still has the name it was created with, "{{name}}".', + action: 'Rename', + }, + languageCountry: { + title: 'Default language and country', + todo: 'Currently {{language}} and {{country}}. Confirm them or choose others.', + saved: 'Default language and country saved', + }, + timezone: { + title: 'System timezone', + todo: 'Still the platform default, {{current}}. It decides what "today" means in formulas and how reports group dates.', + saved: 'System timezone set to {{zone}}', + }, + currency: { + title: 'Default currency', + todo: 'Not set yet, so amounts are shown without a currency symbol.', + saved: 'Default currency set to {{currency}}', + }, + mail: { + title: 'Email delivery', + todo: 'Not set up yet, so emails such as invitations and password resets are not delivered.', + done: 'Set up to deliver emails.', + action: 'Set up', + }, + invite: { + title: 'Invite colleagues', + todo: 'You are the only member so far.', + done: 'Colleagues have joined or been invited.', + action: 'Invite', + }, }, error: { connectionFailed: 'Cannot connect to server', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 35aa9986d54..1ab3e8b439c 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2270,22 +2270,53 @@ const es = { clientAddressDirect: "Su dirección, tal como la juzga el servidor: {{address}}. Una lista que no la incluya no se puede guardar desde aquí. Este servidor no confía en los encabezados de proxy, así que detrás de un proxy esta es la dirección del proxy.", clientAddressViaProxy: "Su dirección, tal como la juzga el servidor: {{address}}, leída de los encabezados reenviados por el proxy en el que confía este servidor. Una lista que no la incluya no se puede guardar desde aquí.", clientAddressUnknown: "El servidor no puede determinar su dirección, así que desde aquí no se puede guardar una lista que no esté vacía.", - }, - workspaceTimezonePrompt: { - title: "Configure la zona horaria del sistema", - description: "Este sistema aún usa la zona horaria predeterminada de la plataforma, {{current}}. La zona horaria del sistema determina qué significa «hoy» en las fórmulas, cómo agrupan los informes por fecha y cómo se muestran las fechas y horas, para todos en el sistema. Abajo se indica la zona horaria de su navegador.", - laterHint: "Puede cambiarla en cualquier momento en la página de Configuración.", - decline: "Mantener la predeterminada", - confirm: "Configurar zona horaria", - saved: "Zona horaria del sistema configurada en {{zone}}", - currencyTitle: "Configure la moneda predeterminada", - currencyDescription: "Esta organización aún no tiene una moneda predeterminada, por lo que los importes se muestran sin símbolo de moneda. Abajo se ha rellenado la moneda de la región de su navegador: {{name}} ({{code}}).", - currencyConfirm: "Configurar moneda", - currencySaved: "Moneda predeterminada configurada en {{currency}}", - bothTitle: "Configure la zona horaria y la moneda predeterminada del sistema", - bothConfirm: "Guardar", - bothSaved: "Zona horaria del sistema configurada en {{zone}} y moneda predeterminada en {{currency}}", - notNow: "Ahora no", + invalidFormat: "{{setting}} no tiene un formato válido.", + invalidFormatHelp: "{{setting}} no tiene un formato válido. {{help}}", + invalidFormatPick: "Elija un valor de la lista para {{setting}}.", + }, + firstRunChecklist: { + title: "Prepare su sistema", + description: "Lo que conviene configurar antes de que empiecen sus compañeros. Cada elemento se puede cambiar más tarde en Setup.", + progress: "{{done}} de {{total}} completados", + dismiss: "Ocultar lista", + set: "Configurar", + change: "Cambiar", + view: "Ver", + confirm: "Confirmar", + cancel: "Cancelar", + prefilled: "Los valores aún sin configurar se sugieren a partir de su navegador. No se guarda nada hasta que confirme.", + organizationName: { + title: "Asigne un nombre a su organización", + todo: "Aún tiene el nombre con el que se creó: «{{name}}».", + action: "Cambiar nombre", + }, + languageCountry: { + title: "Idioma y país predeterminados", + todo: "Actualmente {{language}} y {{country}}. Confírmelos o elija otros.", + saved: "Idioma y país predeterminados guardados", + }, + timezone: { + title: "Zona horaria del sistema", + todo: "Sigue siendo la predeterminada de la plataforma, {{current}}. Determina qué significa «hoy» en las fórmulas y cómo los informes agrupan las fechas.", + saved: "Zona horaria del sistema establecida en {{zone}}", + }, + currency: { + title: "Moneda predeterminada", + todo: "Aún sin configurar, por lo que los importes se muestran sin símbolo de moneda.", + saved: "Moneda predeterminada establecida en {{currency}}", + }, + mail: { + title: "Envío de correo", + todo: "Aún sin configurar, por lo que los correos como invitaciones y restablecimientos de contraseña no se entregan.", + done: "Configurado para entregar correos.", + action: "Configurar", + }, + invite: { + title: "Invitar a compañeros", + todo: "Por ahora es el único miembro.", + done: "Hay compañeros que se han unido o han sido invitados.", + action: "Invitar", + }, }, error: { connectionFailed: "No se puede conectar al servidor", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index f8b55da1b0d..28479c448ec 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2266,22 +2266,53 @@ const fr = { clientAddressDirect: "Votre adresse, telle que le serveur la juge : {{address}}. Une liste qui ne la contient pas ne peut pas être enregistrée d’ici. Ce serveur ne fait pas confiance aux en-têtes de proxy : derrière un proxy, c’est donc l’adresse du proxy.", clientAddressViaProxy: "Votre adresse, telle que le serveur la juge : {{address}}, lue dans les en-têtes transférés par le proxy auquel ce serveur fait confiance. Une liste qui ne la contient pas ne peut pas être enregistrée d’ici.", clientAddressUnknown: "Le serveur ne peut pas établir votre adresse : une liste non vide ne peut donc pas être enregistrée d’ici.", - }, - workspaceTimezonePrompt: { - title: "Définir le fuseau horaire du système", - description: "Ce système utilise encore le fuseau horaire par défaut de la plateforme, {{current}}. Le fuseau horaire du système détermine ce que signifie « aujourd’hui » dans les formules, la façon dont les rapports regroupent les dates et l’affichage des dates et des heures, pour tous les utilisateurs du système. Le fuseau horaire de votre navigateur est renseigné ci-dessous.", - laterHint: "Vous pouvez le modifier à tout moment sur la page Paramètres.", - decline: "Conserver la valeur par défaut", - confirm: "Définir le fuseau horaire", - saved: "Fuseau horaire du système défini sur {{zone}}", - currencyTitle: "Définir la devise par défaut", - currencyDescription: "Cette organisation n'a pas encore de devise par défaut : les montants s'affichent donc sans symbole monétaire. La devise de la région de votre navigateur, {{name}} ({{code}}), est préremplie ci-dessous.", - currencyConfirm: "Définir la devise", - currencySaved: "Devise par défaut définie sur {{currency}}", - bothTitle: "Définir le fuseau horaire et la devise par défaut du système", - bothConfirm: "Enregistrer", - bothSaved: "Fuseau horaire du système défini sur {{zone}} et devise par défaut sur {{currency}}", - notNow: "Plus tard", + invalidFormat: "{{setting}} n’a pas un format valide.", + invalidFormatHelp: "{{setting}} n’a pas un format valide. {{help}}", + invalidFormatPick: "Choisissez une valeur dans la liste pour {{setting}}.", + }, + firstRunChecklist: { + title: "Préparez votre système", + description: "Ce qu’il faut configurer avant que vos collègues commencent. Chaque élément peut être modifié plus tard dans Setup.", + progress: "{{done}} sur {{total}} terminés", + dismiss: "Masquer la liste", + set: "Définir", + change: "Modifier", + view: "Voir", + confirm: "Confirmer", + cancel: "Annuler", + prefilled: "Les valeurs non encore définies sont suggérées d’après votre navigateur. Rien n’est enregistré avant votre confirmation.", + organizationName: { + title: "Nommez votre organisation", + todo: "Elle porte encore le nom donné à sa création : « {{name}} ».", + action: "Renommer", + }, + languageCountry: { + title: "Langue et pays par défaut", + todo: "Actuellement {{language}} et {{country}}. Confirmez-les ou choisissez-en d’autres.", + saved: "Langue et pays par défaut enregistrés", + }, + timezone: { + title: "Fuseau horaire du système", + todo: "Toujours le fuseau par défaut de la plateforme, {{current}}. Il détermine ce que « aujourd’hui » signifie dans les formules et la façon dont les rapports regroupent les dates.", + saved: "Fuseau horaire du système défini sur {{zone}}", + }, + currency: { + title: "Devise par défaut", + todo: "Pas encore définie : les montants s’affichent sans symbole monétaire.", + saved: "Devise par défaut définie sur {{currency}}", + }, + mail: { + title: "Envoi des e-mails", + todo: "Pas encore configuré : les e-mails comme les invitations et les réinitialisations de mot de passe ne sont pas remis.", + done: "Configuré pour remettre les e-mails.", + action: "Configurer", + }, + invite: { + title: "Inviter des collègues", + todo: "Vous êtes le seul membre pour l’instant.", + done: "Des collègues ont rejoint l’organisation ou ont été invités.", + action: "Inviter", + }, }, error: { connectionFailed: "Impossible de se connecter au serveur", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 14ecc4ca422..ad376881bab 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2210,22 +2210,53 @@ const ja = { clientAddressDirect: "サーバーが判定したあなたのアドレス: {{address}}。このアドレスを含まないリストはここから保存できません。このサーバーはプロキシのヘッダーを信頼しないため、プロキシの背後ではプロキシのアドレスになります。", clientAddressViaProxy: "サーバーが判定したあなたのアドレス: {{address}}(このサーバーが信頼するプロキシの転送ヘッダーから取得)。このアドレスを含まないリストはここから保存できません。", clientAddressUnknown: "サーバーがあなたのアドレスを特定できないため、空でないリストはここから保存できません。", - }, - workspaceTimezonePrompt: { - title: "システムのタイムゾーンを設定", - description: "このシステムは、まだプラットフォームの既定のタイムゾーン({{current}})を使用しています。システムのタイムゾーンは、数式での「今日」の意味、レポートの日付ごとのグループ化、日付と時刻の表示方法を決め、システムの全員に適用されます。下にブラウザーのタイムゾーンを入力しています。", - laterHint: "設定ページでいつでも変更できます。", - decline: "既定のままにする", - confirm: "タイムゾーンを設定", - saved: "システムのタイムゾーンを {{zone}} に設定しました", - currencyTitle: "既定の通貨を設定", - currencyDescription: "この組織には既定の通貨がまだ設定されていないため、金額は通貨記号なしで表示されます。ブラウザーの地域の通貨、{{name}}({{code}})を下に入力しました。", - currencyConfirm: "通貨を設定", - currencySaved: "既定の通貨を {{currency}} に設定しました", - bothTitle: "システムのタイムゾーンと既定の通貨を設定", - bothConfirm: "保存", - bothSaved: "システムのタイムゾーンを {{zone}}、既定の通貨を {{currency}} に設定しました", - notNow: "後で", + invalidFormat: "{{setting}} の形式が正しくありません。", + invalidFormatHelp: "{{setting}} の形式が正しくありません。{{help}}", + invalidFormatPick: "{{setting}} は一覧から選んでください。", + }, + firstRunChecklist: { + title: "システムの準備", + description: "同僚が使い始める前に設定しておく項目です。どの項目も後から Setup で変更できます。", + progress: "{{total}} 件中 {{done}} 件完了", + dismiss: "チェックリストを非表示", + set: "設定", + change: "変更", + view: "表示", + confirm: "確定", + cancel: "キャンセル", + prefilled: "未設定の項目にはブラウザーから推測した値が入っています。確定するまで何も保存されません。", + organizationName: { + title: "組織に名前を付ける", + todo: "作成時の名前「{{name}}」のままです。", + action: "名前を変更", + }, + languageCountry: { + title: "既定の言語と国", + todo: "現在は {{language}}、{{country}} です。確定するか、別の値を選んでください。", + saved: "既定の言語と国を保存しました", + }, + timezone: { + title: "システムのタイムゾーン", + todo: "プラットフォームの既定値 {{current}} のままです。数式での「今日」の意味や、レポートで日付をまとめる方法を決めます。", + saved: "システムのタイムゾーンを {{zone}} に設定しました", + }, + currency: { + title: "既定の通貨", + todo: "未設定のため、金額は通貨記号なしで表示されます。", + saved: "既定の通貨を {{currency}} に設定しました", + }, + mail: { + title: "メール配信", + todo: "未設定のため、招待やパスワードリセットなどのメールは配信されません。", + done: "メールを配信できるように設定済みです。", + action: "設定する", + }, + invite: { + title: "同僚を招待", + todo: "現在メンバーはあなただけです。", + done: "同僚が参加済みか、招待済みです。", + action: "招待", + }, }, error: { connectionFailed: "サーバーに接続できません", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index e1939a269f2..2fe7deef071 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2208,22 +2208,53 @@ const ko = { clientAddressDirect: "서버가 판단한 사용자 주소: {{address}}. 이 주소가 포함되지 않은 목록은 여기에서 저장할 수 없습니다. 이 서버는 프록시 헤더를 신뢰하지 않으므로, 프록시 뒤에서는 프록시의 주소가 표시됩니다.", clientAddressViaProxy: "서버가 판단한 사용자 주소: {{address}} (이 서버가 신뢰하는 프록시의 전달 헤더에서 읽음). 이 주소가 포함되지 않은 목록은 여기에서 저장할 수 없습니다.", clientAddressUnknown: "서버가 사용자 주소를 확인할 수 없으므로, 비어 있지 않은 목록은 여기에서 저장할 수 없습니다.", - }, - workspaceTimezonePrompt: { - title: "시스템 시간대 설정", - description: "이 시스템은 아직 플랫폼 기본 시간대({{current}})를 사용하고 있습니다. 시스템 시간대는 수식에서 \"오늘\"의 의미, 보고서의 날짜별 그룹화, 날짜와 시간의 표시 방식을 결정하며 시스템의 모든 사용자에게 적용됩니다. 아래에 브라우저 시간대가 입력되어 있습니다.", - laterHint: "설정 페이지에서 언제든지 변경할 수 있습니다.", - decline: "기본값 유지", - confirm: "시간대 설정", - saved: "시스템 시간대를 {{zone}}(으)로 설정했습니다", - currencyTitle: "기본 통화 설정", - currencyDescription: "이 조직에는 아직 기본 통화가 설정되지 않아 금액이 통화 기호 없이 표시됩니다. 아래에 브라우저 지역의 통화인 {{name}}({{code}})을(를) 채워 두었습니다.", - currencyConfirm: "통화 설정", - currencySaved: "기본 통화를 {{currency}}(으)로 설정했습니다", - bothTitle: "시스템 시간대 및 기본 통화 설정", - bothConfirm: "저장", - bothSaved: "시스템 시간대를 {{zone}}(으)로, 기본 통화를 {{currency}}(으)로 설정했습니다", - notNow: "나중에", + invalidFormat: "{{setting}}의 형식이 올바르지 않습니다.", + invalidFormatHelp: "{{setting}}의 형식이 올바르지 않습니다. {{help}}", + invalidFormatPick: "{{setting}}은(는) 목록에서 선택하세요.", + }, + firstRunChecklist: { + title: "시스템 준비하기", + description: "동료가 사용을 시작하기 전에 설정해 둘 항목입니다. 모든 항목은 나중에 Setup에서 변경할 수 있습니다.", + progress: "{{total}}개 중 {{done}}개 완료", + dismiss: "체크리스트 숨기기", + set: "설정", + change: "변경", + view: "보기", + confirm: "확인", + cancel: "취소", + prefilled: "아직 설정되지 않은 값은 브라우저를 기준으로 제안되었습니다. 확인하기 전에는 아무것도 저장되지 않습니다.", + organizationName: { + title: "조직 이름 정하기", + todo: "아직 생성 시의 이름 \"{{name}}\"을(를) 사용하고 있습니다.", + action: "이름 변경", + }, + languageCountry: { + title: "기본 언어 및 국가", + todo: "현재 {{language}}, {{country}}입니다. 확인하거나 다른 값을 선택하세요.", + saved: "기본 언어 및 국가를 저장했습니다", + }, + timezone: { + title: "시스템 시간대", + todo: "아직 플랫폼 기본값 {{current}}입니다. 수식에서 \"오늘\"의 의미와 보고서에서 날짜를 묶는 방식을 결정합니다.", + saved: "시스템 시간대를 {{zone}}(으)로 설정했습니다", + }, + currency: { + title: "기본 통화", + todo: "아직 설정되지 않아 금액이 통화 기호 없이 표시됩니다.", + saved: "기본 통화를 {{currency}}(으)로 설정했습니다", + }, + mail: { + title: "이메일 발송", + todo: "아직 설정되지 않아 초대, 비밀번호 재설정 등의 이메일이 전달되지 않습니다.", + done: "이메일을 전달하도록 설정되었습니다.", + action: "설정하기", + }, + invite: { + title: "동료 초대", + todo: "현재 구성원은 본인뿐입니다.", + done: "동료가 참여했거나 초대되었습니다.", + action: "초대", + }, }, error: { connectionFailed: "서버에 연결할 수 없습니다", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index df1ffe391fa..b8734193ccc 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2265,22 +2265,53 @@ const pt = { clientAddressDirect: "Seu endereço, como o servidor o avalia: {{address}}. Uma lista que não o inclua não pode ser salva daqui. Este servidor não confia em cabeçalhos de proxy, então, atrás de um proxy, este é o endereço do proxy.", clientAddressViaProxy: "Seu endereço, como o servidor o avalia: {{address}}, lido dos cabeçalhos encaminhados pelo proxy em que este servidor confia. Uma lista que não o inclua não pode ser salva daqui.", clientAddressUnknown: "O servidor não consegue determinar seu endereço, então uma lista não vazia não pode ser salva daqui.", - }, - workspaceTimezonePrompt: { - title: "Definir o fuso horário do sistema", - description: "Este sistema ainda usa o fuso horário padrão da plataforma, {{current}}. O fuso horário do sistema define o que \"hoje\" significa nas fórmulas, como os relatórios agrupam por data e como datas e horas são exibidas, para todos no sistema. O fuso horário do seu navegador foi preenchido abaixo.", - laterHint: "Você pode alterá-lo a qualquer momento na página de Configurações.", - decline: "Manter o padrão", - confirm: "Definir fuso horário", - saved: "Fuso horário do sistema definido como {{zone}}", - currencyTitle: "Definir a moeda padrão", - currencyDescription: "Esta organização ainda não tem uma moeda padrão, por isso os valores são exibidos sem símbolo de moeda. A moeda da região do seu navegador, {{name}} ({{code}}), foi preenchida abaixo.", - currencyConfirm: "Definir moeda", - currencySaved: "Moeda padrão definida como {{currency}}", - bothTitle: "Definir o fuso horário e a moeda padrão do sistema", - bothConfirm: "Salvar", - bothSaved: "Fuso horário do sistema definido como {{zone}} e moeda padrão como {{currency}}", - notNow: "Agora não", + invalidFormat: "{{setting}} não está em um formato válido.", + invalidFormatHelp: "{{setting}} não está em um formato válido. {{help}}", + invalidFormatPick: "Escolha um valor da lista para {{setting}}.", + }, + firstRunChecklist: { + title: "Prepare seu sistema", + description: "O que configurar antes de seus colegas começarem. Cada item pode ser alterado depois em Setup.", + progress: "{{done}} de {{total}} concluídos", + dismiss: "Ocultar lista", + set: "Definir", + change: "Alterar", + view: "Ver", + confirm: "Confirmar", + cancel: "Cancelar", + prefilled: "Os valores ainda não definidos foram sugeridos com base no seu navegador. Nada é salvo até você confirmar.", + organizationName: { + title: "Dê um nome à sua organização", + todo: "Ela ainda tem o nome com que foi criada: \"{{name}}\".", + action: "Renomear", + }, + languageCountry: { + title: "Idioma e país padrão", + todo: "Atualmente {{language}} e {{country}}. Confirme-os ou escolha outros.", + saved: "Idioma e país padrão salvos", + }, + timezone: { + title: "Fuso horário do sistema", + todo: "Ainda é o fuso padrão da plataforma, {{current}}. Ele define o que \"hoje\" significa nas fórmulas e como os relatórios agrupam datas.", + saved: "Fuso horário do sistema definido como {{zone}}", + }, + currency: { + title: "Moeda padrão", + todo: "Ainda não definida, então os valores são exibidos sem símbolo de moeda.", + saved: "Moeda padrão definida como {{currency}}", + }, + mail: { + title: "Envio de e-mails", + todo: "Ainda não configurado, então e-mails como convites e redefinições de senha não são entregues.", + done: "Configurado para entregar e-mails.", + action: "Configurar", + }, + invite: { + title: "Convidar colegas", + todo: "Por enquanto você é o único membro.", + done: "Há colegas que entraram ou foram convidados.", + action: "Convidar", + }, }, error: { connectionFailed: "Não é possível conectar ao servidor", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 2b2331f03d5..b3c7252d2c0 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2334,22 +2334,53 @@ const ru = { clientAddressDirect: "Ваш адрес, как его определяет сервер: {{address}}. Список без этого адреса нельзя сохранить отсюда. Этот сервер не доверяет заголовкам прокси, поэтому за прокси это адрес самого прокси.", clientAddressViaProxy: "Ваш адрес, как его определяет сервер: {{address}}, взят из пересылаемых заголовков прокси, которому доверяет этот сервер. Список без этого адреса нельзя сохранить отсюда.", clientAddressUnknown: "Сервер не может определить ваш адрес, поэтому непустой список нельзя сохранить отсюда.", - }, - workspaceTimezonePrompt: { - title: "Задайте часовой пояс системы", - description: "В этой системе всё ещё используется часовой пояс платформы по умолчанию — {{current}}. Часовой пояс системы определяет, что означает «сегодня» в формулах, как отчёты группируют данные по датам и как отображаются дата и время, — для всех в системе. Ниже указан часовой пояс вашего браузера.", - laterHint: "Его можно изменить в любое время на странице «Настройки».", - decline: "Оставить по умолчанию", - confirm: "Задать часовой пояс", - saved: "Часовой пояс системы: {{zone}}", - currencyTitle: "Задайте валюту по умолчанию", - currencyDescription: "Для этой организации ещё не задана валюта по умолчанию, поэтому суммы отображаются без символа валюты. Ниже указана валюта региона вашего браузера: {{name}} ({{code}}).", - currencyConfirm: "Задать валюту", - currencySaved: "Валюта по умолчанию: {{currency}}", - bothTitle: "Задайте часовой пояс и валюту по умолчанию", - bothConfirm: "Сохранить", - bothSaved: "Часовой пояс системы: {{zone}}, валюта по умолчанию: {{currency}}", - notNow: "Не сейчас", + invalidFormat: "Неверный формат значения «{{setting}}».", + invalidFormatHelp: "Неверный формат значения «{{setting}}». {{help}}", + invalidFormatPick: "Выберите значение «{{setting}}» из списка.", + }, + firstRunChecklist: { + title: "Подготовьте систему", + description: "Что стоит настроить до того, как начнут работать коллеги. Любой пункт можно позже изменить в Setup.", + progress: "Выполнено {{done}} из {{total}}", + dismiss: "Скрыть список", + set: "Задать", + change: "Изменить", + view: "Открыть", + confirm: "Подтвердить", + cancel: "Отмена", + prefilled: "Для ещё не заданных значений предложены значения из вашего браузера. Ничего не сохраняется, пока вы не подтвердите.", + organizationName: { + title: "Назовите организацию", + todo: "У неё всё ещё имя, данное при создании: «{{name}}».", + action: "Переименовать", + }, + languageCountry: { + title: "Язык и страна по умолчанию", + todo: "Сейчас: {{language}}, {{country}}. Подтвердите их или выберите другие.", + saved: "Язык и страна по умолчанию сохранены", + }, + timezone: { + title: "Часовой пояс системы", + todo: "Всё ещё часовой пояс платформы по умолчанию, {{current}}. Он определяет, что означает «сегодня» в формулах и как отчёты группируют даты.", + saved: "Часовой пояс системы: {{zone}}", + }, + currency: { + title: "Валюта по умолчанию", + todo: "Ещё не задана, поэтому суммы показываются без символа валюты.", + saved: "Валюта по умолчанию: {{currency}}", + }, + mail: { + title: "Отправка почты", + todo: "Ещё не настроена, поэтому письма, например приглашения и сброс пароля, не доставляются.", + done: "Доставка писем настроена.", + action: "Настроить", + }, + invite: { + title: "Пригласите коллег", + todo: "Пока вы единственный участник.", + done: "Коллеги присоединились или приглашены.", + action: "Пригласить", + }, }, error: { connectionFailed: "Не удаётся подключиться к серверу", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 4d46132b20b..aeb1c4bf3c6 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1978,22 +1978,53 @@ const zh = { clientAddressDirect: '服务器判定的你的地址:{{address}}。不包含该地址的列表无法在此保存。此服务器不信任代理转发头,若部署在代理之后,这里显示的是代理的地址。', clientAddressViaProxy: '服务器判定的你的地址:{{address}},取自此服务器所信任代理的转发头。不包含该地址的列表无法在此保存。', clientAddressUnknown: '服务器无法确定你的地址,因此无法在此保存非空的列表。', - }, - workspaceTimezonePrompt: { - title: '设置系统时区', - description: '本系统仍在使用平台默认时区 {{current}}。系统时区决定公式中“今天”的含义、报表如何按日期分组,以及日期和时间如何显示,对系统内所有人生效。下方已填入你的浏览器时区。', - laterHint: '之后可随时在“设置”页面中修改。', - decline: '保持默认', - confirm: '设置时区', - saved: '系统时区已设置为 {{zone}}', - currencyTitle: '设置默认货币', - currencyDescription: '本组织尚未设置默认货币,金额暂不显示币种。下方已填入您浏览器所在地区的货币:{{name}}({{code}})。', - currencyConfirm: '设置货币', - currencySaved: '默认货币已设置为 {{currency}}', - bothTitle: '设置系统时区和默认货币', - bothConfirm: '保存', - bothSaved: '系统时区已设置为 {{zone}},默认货币已设置为 {{currency}}', - notNow: '暂不设置', + invalidFormat: '{{setting}}的格式不正确。', + invalidFormatHelp: '{{setting}}的格式不正确。{{help}}', + invalidFormatPick: '请从列表中选择{{setting}}。', + }, + firstRunChecklist: { + title: '完成初始设置', + description: '同事开始使用之前,先设置好这几项。每一项之后都可以在 Setup 中修改。', + progress: '已完成 {{done}}/{{total}}', + dismiss: '隐藏清单', + set: '设置', + change: '修改', + view: '查看', + confirm: '确认', + cancel: '取消', + prefilled: '尚未设置的项已按你的浏览器给出建议。确认之前不会保存任何内容。', + organizationName: { + title: '为组织命名', + todo: '组织仍在使用创建时的名称“{{name}}”。', + action: '重命名', + }, + languageCountry: { + title: '默认语言和国家/地区', + todo: '当前为{{language}}、{{country}}。请确认,或改选其他值。', + saved: '默认语言和国家/地区已保存', + }, + timezone: { + title: '系统时区', + todo: '仍为平台默认时区 {{current}}。系统时区决定公式中“今天”的含义,以及报表如何按日期分组。', + saved: '系统时区已设置为 {{zone}}', + }, + currency: { + title: '默认货币', + todo: '尚未设置,金额暂不显示币种。', + saved: '默认货币已设置为 {{currency}}', + }, + mail: { + title: '邮件发送', + todo: '尚未配置,邀请、重置密码等邮件暂时无法送达。', + done: '已配置,可以发送邮件。', + action: '去配置', + }, + invite: { + title: '邀请同事', + todo: '目前只有你一位成员。', + done: '已有同事加入或已发出邀请。', + action: '邀请', + }, }, error: { connectionFailed: '无法连接到服务器',