Skip to content
13 changes: 13 additions & 0 deletions .changeset/11758-workspace-timezone-prompt.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions apps/console/src/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })));
Expand Down Expand Up @@ -309,6 +310,11 @@ export function AppContent() {
<LocalizationFetchProvider endpoint={localizationEndpoint}>
<DefaultAppContent extraRoutes={systemRoutes} extraRoutesNoApp={systemRoutes} />
</LocalizationFetchProvider>
{/* 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. */}
<WorkspaceTimezonePrompt />
</MePermissionsProvider>
);
}
189 changes: 189 additions & 0 deletions apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* <WorkspaceTimezonePrompt> — 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 `<SettingsField>` 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<TimezoneOffer | null>(null);
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState('');
const [saving, setSaving] = useState(false);
const [fieldError, setFieldError] = useState<string | undefined>(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) {
// 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.
// `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 = extractFieldErrors(apiError)?.find((f) => f.field === TIMEZONE_KEY);
if (refused) setFieldError(refused.message);
toast.error(failure?.message ?? t('console.settingsView.saveFailed'));
}
} finally {
setSaving(false);
}
};

return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next && !saving) setOpen(false);
}}
>
<DialogContent className="sm:max-w-md" data-testid="workspace-timezone-prompt">
<DialogHeader>
<DialogTitle>{t('console.workspaceTimezonePrompt.title')}</DialogTitle>
<DialogDescription>
{t('console.workspaceTimezonePrompt.description', { current: offer.current })}
</DialogDescription>
</DialogHeader>
<SettingsField
spec={offer.spec}
resolved={offer.resolved}
value={draft}
onChange={(next) => {
setDraft(next == null ? '' : String(next));
setFieldError(undefined);
}}
saving={saving}
labels={labels}
error={fieldError}
/>
<p className="text-xs text-muted-foreground">
{t('console.workspaceTimezonePrompt.laterHint')}
</p>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setOpen(false)}
disabled={saving}
data-testid="workspace-timezone-prompt-decline"
>
{t('console.workspaceTimezonePrompt.decline')}
</Button>
<Button
onClick={onConfirm}
disabled={saving || !draft}
data-testid="workspace-timezone-prompt-confirm"
>
{saving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
{t('console.workspaceTimezonePrompt.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading
Loading