From f2be28b81dccc5d40caf2662f4ddbc49a41cc6c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:45:37 +0000 Subject: [PATCH 1/6] feat(console): ask an administrator once to set a still-default workspace timezone (objectui#11758) The prompt reads `localization.timezone` through the Settings page's own client, shows only while the value is the manifest default and the session holds the manifest's write permission, pre-fills the browser's zone, and confirms through `saveSettingsNamespace`. Decline writes nothing. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- apps/console/src/AppContent.tsx | 6 + .../settings/WorkspaceTimezonePrompt.tsx | 187 ++++++++++++++++ .../pages/settings/workspaceTimezonePrompt.ts | 211 ++++++++++++++++++ packages/i18n/src/locales/ar.ts | 8 + packages/i18n/src/locales/de.ts | 8 + packages/i18n/src/locales/en.ts | 11 + packages/i18n/src/locales/es.ts | 8 + packages/i18n/src/locales/fr.ts | 8 + packages/i18n/src/locales/ja.ts | 8 + packages/i18n/src/locales/ko.ts | 8 + packages/i18n/src/locales/pt.ts | 8 + packages/i18n/src/locales/ru.ts | 8 + packages/i18n/src/locales/zh.ts | 8 + 13 files changed, 487 insertions(+) create mode 100644 apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx create mode 100644 apps/console/src/pages/settings/workspaceTimezonePrompt.ts diff --git a/apps/console/src/AppContent.tsx b/apps/console/src/AppContent.tsx index 5bb003988f..2a74707962 100644 --- a/apps/console/src/AppContent.tsx +++ b/apps/console/src/AppContent.tsx @@ -14,6 +14,7 @@ import { DefaultAppContent, LoadingScreen } from '@object-ui/app-shell'; import { MePermissionsProvider } from '@object-ui/permissions'; import { createAuthenticatedFetch } from '@object-ui/auth'; import { LocalizationFetchProvider } from './LocalizationFetchProvider'; +import { WorkspaceTimezonePrompt } from './pages/settings/WorkspaceTimezonePrompt'; const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage }))); const ProfilePage = lazy(() => import('./pages/system/ProfilePage').then(m => ({ default: m.ProfilePage }))); @@ -309,6 +310,11 @@ export function AppContent() { + {/* objectui#11758 — asks an administrator once to set a still-default + workspace timezone. Here, inside `MePermissionsProvider`, 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/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx b/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx new file mode 100644 index 0000000000..6b66b6a694 --- /dev/null +++ b/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx @@ -0,0 +1,187 @@ +// 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). + * + * 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 `workspaceTimezonePrompt.ts`; this file is the dialog. + * + * Both halves of the write are the Settings page's own: + * + * - the FIELD is `` rendering the manifest's own `timezone` + * specifier, so the zone is edited in the same control (the `valueDomain` + * combobox with the curated zones as suggestions), with the same translated + * label and help, and a refusal lands in the same error slot; + * - the WRITE is `saveSettingsNamespace('localization', { timezone })`, 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. + */ + +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 { + LOCALIZATION_NAMESPACE, + TIMEZONE_KEY, + browserTimeZone, + deviceStorage, + findTimezoneOffer, + markAsked, + promptRecordKey, + wasAsked, + type TimezoneOffer, +} from './workspaceTimezonePrompt'; + +export function WorkspaceTimezonePrompt() { + const { t } = useObjectTranslation(); + const { user, activeOrganization } = useAuth(); + const { systemPermissions } = usePermissions(); + const labels = useSettingsLabel(LOCALIZATION_NAMESPACE); + + const [offer, setOffer] = useState(null); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(''); + const [saving, setSaving] = useState(false); + const [fieldError, setFieldError] = useState(undefined); + + 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 zone = browserTimeZone(); + if (!zone) return; + const held = heldKey === '' ? [] : heldKey.split('\n'); + + let cancelled = false; + findTimezoneOffer(held, zone) + .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); + setDraft(found.zone); + 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 onConfirm = async () => { + if (!draft || saving) return; + setSaving(true); + setFieldError(undefined); + try { + await saveSettingsNamespace(LOCALIZATION_NAMESPACE, { [TIMEZONE_KEY]: draft }); + toast.success(t('console.workspaceTimezonePrompt.saved', { zone: draft })); + setOpen(false); + } catch (err: any) { + // The Settings page's own reading of a refused save, for the one key + // 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. + const apiError = err?.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 = extractFieldErrors(apiError)?.find((f) => f.field === TIMEZONE_KEY); + if (refused) setFieldError(refused.message); + toast.error(err?.message ?? t('console.settingsView.saveFailed')); + } + } finally { + setSaving(false); + } + }; + + return ( + { + if (!next && !saving) setOpen(false); + }} + > + + + {t('console.workspaceTimezonePrompt.title')} + + {t('console.workspaceTimezonePrompt.description', { current: offer.current })} + + + { + setDraft(next == null ? '' : String(next)); + setFieldError(undefined); + }} + saving={saving} + labels={labels} + error={fieldError} + /> +

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

+ + + + +
+
+ ); +} diff --git a/apps/console/src/pages/settings/workspaceTimezonePrompt.ts b/apps/console/src/pages/settings/workspaceTimezonePrompt.ts new file mode 100644 index 0000000000..eca94a1964 --- /dev/null +++ b/apps/console/src/pages/settings/workspaceTimezonePrompt.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * When the console asks an administrator to set a still-default workspace + * timezone, and what it offers (objectui#11758). + * + * 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. + * + * 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 { 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'; + +/** + * 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. + */ +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; + } +} + +/** + * Would the settings write door admit `zone` 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 time-zone data and this runs on the + * browser's, so a zone 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 isAdmissibleZone(spec: Specifier, zone: string): boolean { + if (spec.valueDomain) return isValueDomainMember(spec.valueDomain, zone); + return (spec.options ?? []).some((option) => String(option.value) === 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); +} + +/** What the prompt offers: 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; +} + +/** + * Is `localization.timezone` still the manifest default, may this session + * write it, and does the browser have a zone the door would take? Answers the + * offer, or `null` for "do not ask". + * + * 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. + * + * "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 findTimezoneOffer( + systemPermissions: readonly string[] | undefined, + zone: string, +): 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 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 }; +} diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index b94cab4d2e..9369797fe8 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2155,6 +2155,14 @@ const ar = { cryptoRefusalToast: "تعذّر تشفير الأسرار: {{subject}}", cryptoRefusalToastNoSubject: "تعذّر تشفير الأسرار", }, + workspaceTimezonePrompt: { + title: "تعيين المنطقة الزمنية لمساحة العمل", + description: "لا تزال مساحة العمل هذه تستخدم المنطقة الزمنية الافتراضية للمنصة، {{current}}. تحدد المنطقة الزمنية لمساحة العمل معنى \"اليوم\" في الصيغ، وكيفية تجميع التقارير حسب التاريخ، وكيفية عرض التواريخ والأوقات، لجميع أعضاء مساحة العمل. تم إدخال المنطقة الزمنية لمتصفحك أدناه.", + laterHint: "يمكنك تغييرها في أي وقت من صفحة الإعدادات.", + decline: "الإبقاء على الافتراضي", + confirm: "تعيين المنطقة الزمنية", + saved: "تم تعيين المنطقة الزمنية لمساحة العمل إلى {{zone}}", + }, loadingSteps: { connecting: "جارٍ الاتصال بمصدر البيانات", loadingConfig: "جارٍ تحميل الإعدادات", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index a1af01a70f..56f9291df0 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1966,6 +1966,14 @@ const de = { cryptoRefusalToast: "Geheimnisse können nicht verschlüsselt werden: {{subject}}", cryptoRefusalToastNoSubject: "Geheimnisse können nicht verschlüsselt werden", }, + workspaceTimezonePrompt: { + title: "Zeitzone des Workspace festlegen", + description: "Dieser Workspace verwendet noch die Standardzeitzone der Plattform, {{current}}. Die Zeitzone des Workspace bestimmt, was „heute“ in Formeln bedeutet, wie Berichte nach Datum gruppieren und wie Datum und Uhrzeit angezeigt werden – für alle im Workspace. Unten ist die Zeitzone Ihres Browsers eingetragen.", + laterHint: "Sie können sie jederzeit auf der Seite „Einstellungen“ ändern.", + decline: "Standard beibehalten", + confirm: "Zeitzone festlegen", + saved: "Zeitzone des Workspace auf {{zone}} festgelegt", + }, loadingSteps: { connecting: "Verbindung zur Datenquelle herstellen", loadingConfig: "Konfiguration laden", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index bd221e6869..14d49c9eb4 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2061,6 +2061,17 @@ const en = { cryptoRefusalToast: 'Cannot encrypt secrets: {{subject}}', cryptoRefusalToastNoSubject: 'Cannot encrypt secrets', }, + // 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 workspace timezone', + description: 'This workspace still uses the platform default timezone, {{current}}. The workspace timezone decides what "today" means in formulas, how reports group dates and how dates and times are shown, for everyone in the workspace. 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: 'Workspace timezone set to {{zone}}', + }, loadingSteps: { connecting: 'Connecting to data source', loadingConfig: 'Loading configuration', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 23c8528dfe..d547f8dde0 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2013,6 +2013,14 @@ const es = { cryptoRefusalToast: "No se pueden cifrar los secretos: {{subject}}", cryptoRefusalToastNoSubject: "No se pueden cifrar los secretos", }, + workspaceTimezonePrompt: { + title: "Configure la zona horaria del espacio de trabajo", + description: "Este espacio de trabajo aún usa la zona horaria predeterminada de la plataforma, {{current}}. La zona horaria del espacio de trabajo 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 espacio de trabajo. 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 espacio de trabajo configurada en {{zone}}", + }, loadingSteps: { connecting: "Conectando a la fuente de datos", loadingConfig: "Cargando configuración", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index bd25f8a8b6..b9196eb50b 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2010,6 +2010,14 @@ const fr = { cryptoRefusalToast: "Impossible de chiffrer les secrets : {{subject}}", cryptoRefusalToastNoSubject: "Impossible de chiffrer les secrets", }, + workspaceTimezonePrompt: { + title: "Définir le fuseau horaire de l’espace de travail", + description: "Cet espace de travail utilise encore le fuseau horaire par défaut de la plateforme, {{current}}. Le fuseau horaire de l’espace de travail 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 membres de l’espace de travail. 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 de l’espace de travail défini sur {{zone}}", + }, loadingSteps: { connecting: "Connexion à la source de données", loadingConfig: "Chargement de la configuration", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index ee1a83027b..dcdfe88be5 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1968,6 +1968,14 @@ const ja = { cryptoRefusalToast: "シークレットを暗号化できません: {{subject}}", cryptoRefusalToastNoSubject: "シークレットを暗号化できません", }, + workspaceTimezonePrompt: { + title: "ワークスペースのタイムゾーンを設定", + description: "このワークスペースは、まだプラットフォームの既定のタイムゾーン({{current}})を使用しています。ワークスペースのタイムゾーンは、数式での「今日」の意味、レポートの日付ごとのグループ化、日付と時刻の表示方法を決め、ワークスペースの全員に適用されます。下にブラウザーのタイムゾーンを入力しています。", + laterHint: "設定ページでいつでも変更できます。", + decline: "既定のままにする", + confirm: "タイムゾーンを設定", + saved: "ワークスペースのタイムゾーンを {{zone}} に設定しました", + }, loadingSteps: { connecting: "データソースに接続中", loadingConfig: "設定を読み込み中", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 073eb75de8..44f028cc49 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1966,6 +1966,14 @@ const ko = { cryptoRefusalToast: "시크릿을 암호화할 수 없습니다: {{subject}}", cryptoRefusalToastNoSubject: "시크릿을 암호화할 수 없습니다", }, + workspaceTimezonePrompt: { + title: "워크스페이스 시간대 설정", + description: "이 워크스페이스는 아직 플랫폼 기본 시간대({{current}})를 사용하고 있습니다. 워크스페이스 시간대는 수식에서 \"오늘\"의 의미, 보고서의 날짜별 그룹화, 날짜와 시간의 표시 방식을 결정하며 워크스페이스의 모든 사용자에게 적용됩니다. 아래에 브라우저 시간대가 입력되어 있습니다.", + laterHint: "설정 페이지에서 언제든지 변경할 수 있습니다.", + decline: "기본값 유지", + confirm: "시간대 설정", + saved: "워크스페이스 시간대를 {{zone}}(으)로 설정했습니다", + }, loadingSteps: { connecting: "데이터 소스에 연결 중", loadingConfig: "설정 로드 중", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 4948b93f60..913ef30273 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2008,6 +2008,14 @@ const pt = { cryptoRefusalToast: "Não é possível criptografar segredos: {{subject}}", cryptoRefusalToastNoSubject: "Não é possível criptografar segredos", }, + workspaceTimezonePrompt: { + title: "Definir o fuso horário do workspace", + description: "Este workspace ainda usa o fuso horário padrão da plataforma, {{current}}. O fuso horário do workspace 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 workspace. 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 workspace definido como {{zone}}", + }, loadingSteps: { connecting: "Conectando à fonte de dados", loadingConfig: "Carregando configuração", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index d77801eb3a..cf02fc0528 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2069,6 +2069,14 @@ const ru = { cryptoRefusalToast: "Не удалось зашифровать секреты: {{subject}}", cryptoRefusalToastNoSubject: "Не удалось зашифровать секреты", }, + workspaceTimezonePrompt: { + title: "Задайте часовой пояс рабочего пространства", + description: "В этом рабочем пространстве всё ещё используется часовой пояс платформы по умолчанию — {{current}}. Часовой пояс рабочего пространства определяет, что означает «сегодня» в формулах, как отчёты группируют данные по датам и как отображаются дата и время, — для всех в рабочем пространстве. Ниже указан часовой пояс вашего браузера.", + laterHint: "Его можно изменить в любое время на странице «Настройки».", + decline: "Оставить по умолчанию", + confirm: "Задать часовой пояс", + saved: "Часовой пояс рабочего пространства: {{zone}}", + }, loadingSteps: { connecting: "Подключение к источнику данных", loadingConfig: "Загрузка конфигурации", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 6c0f2e2e77..63c82187e5 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1763,6 +1763,14 @@ const zh = { cryptoRefusalToast: '无法加密机密信息:{{subject}}', cryptoRefusalToastNoSubject: '无法加密机密信息', }, + workspaceTimezonePrompt: { + title: '设置工作区时区', + description: '此工作区仍在使用平台默认时区 {{current}}。工作区时区决定公式中“今天”的含义、报表如何按日期分组,以及日期和时间如何显示,对工作区内所有人生效。下方已填入你的浏览器时区。', + laterHint: '之后可随时在“设置”页面中修改。', + decline: '保持默认', + confirm: '设置时区', + saved: '工作区时区已设置为 {{zone}}', + }, loadingSteps: { connecting: '正在连接数据源', loadingConfig: '正在加载配置', From 75c5e44ee920aa6ecf3d1feece13b77cfb597728 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:48:24 +0000 Subject: [PATCH 2/6] test(console): pin the workspace-timezone prompt's acceptance (objectui#11758) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../WorkspaceTimezonePrompt.test.tsx | 370 ++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.test.tsx diff --git a/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.test.tsx b/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.test.tsx new file mode 100644 index 0000000000..2d63e6d473 --- /dev/null +++ b/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.test.tsx @@ -0,0 +1,370 @@ +/** + * 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 } 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 '../workspaceTimezonePrompt'; +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: MePermissionsResponse = { + authenticated: true, + userId: 'u_admin', + tenantId: 'org_1', + roles: [], + 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 workspace 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 workspace 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('Workspace 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; + 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); + }); +}); From 36b2d25fca921ace92d6043cd9817f566d186d62 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:54:54 +0000 Subject: [PATCH 3/6] chore(changeset): declare the workspace-timezone prompt (objectui#11758) Also reads a refused save without an `any`. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11758-workspace-timezone-prompt.md | 13 +++++++++++++ .../src/pages/settings/WorkspaceTimezonePrompt.tsx | 8 +++++--- 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 .changeset/11758-workspace-timezone-prompt.md diff --git a/.changeset/11758-workspace-timezone-prompt.md b/.changeset/11758-workspace-timezone-prompt.md new file mode 100644 index 0000000000..627dff0f61 --- /dev/null +++ b/.changeset/11758-workspace-timezone-prompt.md @@ -0,0 +1,13 @@ +--- +'@object-ui/console': minor +'@object-ui/i18n': minor +--- + +The console asks an administrator once to set the workspace timezone while it is still the platform default, pre-filled with the browser's zone (objectui#11758, the gate the objectui#11693 ruling put on rendering instants in the workspace's zone). + +- **When it asks.** On opening an app, when `localization.timezone` resolves from the manifest default (no env, global or tenant value, and not locked), the session holds the localization manifest's `writePermission`, and the browser reports a zone the settings door admits: the manifest's declared `iana_time_zone` domain, judged by `isValueDomainMember` from `@objectstack/spec/shared`. A session that may not write settings is never asked, and reads nothing beyond the settings list. +- **Once.** The prompt is recorded as shown, on this device, per administrator and per workspace, at the moment it opens. It does not ask again there, whatever the answer. +- **Confirm** writes `localization.timezone` with the chosen zone through the Settings page's own save (`PUT /api/settings/localization`), so the same permission check and audit apply, and a refused zone shows in the field as it does on the Settings page. **Decline**, or closing the dialog, writes nothing. +- The zone is edited in the Settings page's own timezone field: any IANA zone can be typed, with the curated zones as suggestions. + +New language-pack keys in `@object-ui/i18n`, in all ten packs: `console.workspaceTimezonePrompt.title`, `.description` (interpolates `{{current}}`), `.laterHint`, `.decline`, `.confirm` and `.saved` (interpolates `{{zone}}`). No export, prop or type member is added. diff --git a/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx b/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx index 6b66b6a694..67bf46a968 100644 --- a/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx +++ b/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx @@ -112,11 +112,13 @@ export function WorkspaceTimezonePrompt() { await saveSettingsNamespace(LOCALIZATION_NAMESPACE, { [TIMEZONE_KEY]: draft }); toast.success(t('console.workspaceTimezonePrompt.saved', { zone: draft })); setOpen(false); - } catch (err: any) { + } catch (err) { // The Settings page's own reading of a refused save, for the one key // 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. - const apiError = err?.payload?.error; + // `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( @@ -127,7 +129,7 @@ export function WorkspaceTimezonePrompt() { } else { const refused = extractFieldErrors(apiError)?.find((f) => f.field === TIMEZONE_KEY); if (refused) setFieldError(refused.message); - toast.error(err?.message ?? t('console.settingsView.saveFailed')); + toast.error(failure?.message ?? t('console.settingsView.saveFailed')); } } finally { setSaving(false); From 738fae190109c186880659b54027590bafb4018b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:56:22 +0000 Subject: [PATCH 4/6] test(e2e): answer the workspace-timezone prompt once in the live global setup (objectui#11758) A fresh live backend leaves `localization.timezone` at the manifest default and the seeded admin may write settings, so the console's new one-time modal would stand in front of every spec. Global setup answers it once in a real browser (decline by default, `LIVE_TIMEZONE_PROMPT=confirm` to confirm) and keeps the outcome in the shared storageState. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- e2e/live/global-setup.ts | 72 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/e2e/live/global-setup.ts b/e2e/live/global-setup.ts index 7646ba7692..908824135d 100644 --- a/e2e/live/global-setup.ts +++ b/e2e/live/global-setup.ts @@ -1,4 +1,4 @@ -import { request } from '@playwright/test'; +import { chromium, request } from '@playwright/test'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; @@ -62,4 +62,74 @@ export default async function globalSetup() { 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}`); + + await answerTimezonePrompt(token); +} + +/** + * Answer the console's one-time workspace-timezone prompt before any spec runs + * (objectui#11758). + * + * On a `--fresh` backend `localization.timezone` is still the manifest default + * and the seeded admin may write settings, so the console opens a modal prompt + * 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) "Keep the default": 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 "Set timezone": the browser's zone 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 (a zone already + * chosen, or a locked one). 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) { + 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 timezone = (body?.data ?? body)?.values?.timezone; + wouldAsk = timezone?.source === 'default' && !timezone?.locked; + } + } finally { + await api.dispose(); + } + if (!wouldAsk) { + // eslint-disable-next-line no-console + console.log('[live-e2e] workspace timezone already chosen; no prompt to answer'); + return; + } + + const browser = await chromium.launch(); + 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 is the default, 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 timezone prompt answered: ${mode}`); + } finally { + await browser.close(); + } } From 05742b4de480d7b95be2cd0eef106c3410dc5910 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:11:08 +0000 Subject: [PATCH 5/6] test(e2e): answer the timezone prompt in the specs' own browser (objectui#11758) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- e2e/live/global-setup.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/e2e/live/global-setup.ts b/e2e/live/global-setup.ts index 908824135d..e955aaa4e4 100644 --- a/e2e/live/global-setup.ts +++ b/e2e/live/global-setup.ts @@ -1,4 +1,4 @@ -import { chromium, request } from '@playwright/test'; +import { chromium, request, type FullConfig, type LaunchOptions } 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() { +export default async function globalSetup(config: FullConfig) { const ctx = await request.newContext(); let res; try { @@ -63,7 +63,9 @@ export default async function globalSetup() { // eslint-disable-next-line no-console console.log(`[live-e2e] authenticated as ${EMAIL}; storageState written to ${STATE_PATH}`); - await answerTimezonePrompt(token); + // 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); } /** @@ -90,7 +92,7 @@ export default async function globalSetup() { * reported, not failed: the specs then show whether anything stands in their * way. */ -async function answerTimezonePrompt(token: string) { +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; @@ -110,7 +112,7 @@ async function answerTimezonePrompt(token: string) { return; } - const browser = await chromium.launch(); + const browser = await chromium.launch(launchOptions); try { const context = await browser.newContext({ storageState: STATE_PATH, baseURL: APP }); const page = await context.newPage(); From 74e0383ca31b88f20b1c5b17363729926e016d4f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:15:37 +0000 Subject: [PATCH 6/6] fix(i18n): keep the timezone prompt's de copy unquoted and declare its zone read (objectui#11758) The de copy now says what it means without German quotes, so the pinned quote-pair count stands. The machine-locale census declares the prompt's one `Intl.DateTimeFormat()` call: it reads the host time zone, formats nothing. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../i18n/src/__tests__/machineLocaleCensus-9909.test.ts | 9 +++++++++ packages/i18n/src/locales/de.ts | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts index 892383fe2b..635ef5b48e 100644 --- a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts +++ b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts @@ -248,6 +248,15 @@ const DECLARED: Exemption[] = [ 'an ICU metadata PROBE for the ISO-4217 fraction-digit count, which is locale-independent; nothing is ' + 'formatted and no face is rendered.', }, + { + file: 'apps/console/src/pages/settings/workspaceTimezonePrompt.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.', + }, { file: 'packages/components/src/ui/calendar.tsx', expression: 'data-day={day.date.toLocaleDateString()}', diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 56f9291df0..eadd5ecca7 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1968,8 +1968,8 @@ const de = { }, workspaceTimezonePrompt: { title: "Zeitzone des Workspace festlegen", - description: "Dieser Workspace verwendet noch die Standardzeitzone der Plattform, {{current}}. Die Zeitzone des Workspace bestimmt, was „heute“ in Formeln bedeutet, wie Berichte nach Datum gruppieren und wie Datum und Uhrzeit angezeigt werden – für alle im Workspace. Unten ist die Zeitzone Ihres Browsers eingetragen.", - laterHint: "Sie können sie jederzeit auf der Seite „Einstellungen“ ändern.", + description: "Dieser Workspace verwendet noch die Standardzeitzone der Plattform, {{current}}. Die Zeitzone des Workspace bestimmt, welcher Tag in Formeln als heute gilt, wie Berichte nach Datum gruppieren und wie Datum und Uhrzeit angezeigt werden – für alle im Workspace. 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 Workspace auf {{zone}} festgelegt",