From e939b429dfacb3d056a6812c77339690c1d8dea0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:16:01 +0000 Subject: [PATCH 1/4] feat(auth): createOrganization carries an optional timezone on the create query (objectui#11908) The create route reads the creator's IANA zone from `?timezone=` and keeps the body `{ name, slug }`. `createOrganization`'s input gains an optional `timezone` on `AuthClient` and `AuthContextValue`; the client lifts it onto the query through better-auth's `fetchOptions.query`. An absent or empty zone sends no parameter. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- packages/auth/src/AuthContext.ts | 8 +- packages/auth/src/AuthProvider.tsx | 2 +- .../createOrganization-timezone-11908.test.ts | 86 +++++++++++++++++++ packages/auth/src/createAuthClient.ts | 9 +- packages/auth/src/types.ts | 12 ++- 5 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 packages/auth/src/__tests__/createOrganization-timezone-11908.test.ts diff --git a/packages/auth/src/AuthContext.ts b/packages/auth/src/AuthContext.ts index 157adbc619..290e7cc33b 100644 --- a/packages/auth/src/AuthContext.ts +++ b/packages/auth/src/AuthContext.ts @@ -136,8 +136,12 @@ export interface AuthContextValue { isMembershipResolved: boolean; /** Switch the active organization (workspace) */ switchOrganization: (orgId: string) => Promise; - /** Create a new organization */ - createOrganization: (data: { name: string; slug: string; logo?: string }) => Promise; + /** + * Create a new organization. `timezone` is the creator's IANA zone, sent as + * the `timezone` query parameter, never in the body; see + * `AuthClient.createOrganization` (objectui#11908). + */ + createOrganization: (data: { name: string; slug: string; logo?: string; timezone?: string }) => Promise; /** * Refresh the organizations list. Returns the freshly fetched list so * callers that need it right away (e.g. the first-run wizard's rename diff --git a/packages/auth/src/AuthProvider.tsx b/packages/auth/src/AuthProvider.tsx index 1ad0ddd92f..c8edc3533f 100644 --- a/packages/auth/src/AuthProvider.tsx +++ b/packages/auth/src/AuthProvider.tsx @@ -843,7 +843,7 @@ export function AuthProvider({ ); const createOrganization = useCallback( - async (data: { name: string; slug: string; logo?: string }): Promise => { + async (data: { name: string; slug: string; logo?: string; timezone?: string }): Promise => { setError(null); try { const org = await client.createOrganization(data); diff --git a/packages/auth/src/__tests__/createOrganization-timezone-11908.test.ts b/packages/auth/src/__tests__/createOrganization-timezone-11908.test.ts new file mode 100644 index 0000000000..d8ab3cae4a --- /dev/null +++ b/packages/auth/src/__tests__/createOrganization-timezone-11908.test.ts @@ -0,0 +1,86 @@ +/** + * objectui#11908 — `createOrganization` carries the creator's zone on the + * QUERY of `POST /organization/create`, never in the body. + * + * The wire shape is the server's (triage's unlock on the card): the create + * route reads `?timezone=IANA_ZONE` and its body stays `{ name, slug }`. The + * console's two creation call sites mock `useAuth`, so their pins see only the + * argument object; this file is the link that sees the request. It drives the + * REAL better-auth client through a stubbed `fetch`, the same way + * `createAuthClient.test.ts` and `org-error-code-4474.test.ts` next door do, + * and asserts the URL's query and the body's exact keys as they leave. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createAuthClient } from '../createAuthClient'; + +interface Sent { + url: URL; + method: string; + body: Record; +} + +/** A fetch that answers `/organization/create` and records what was sent. */ +function recordingFetch() { + const sent: Sent[] = []; + const fetchFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as Record) : {}; + sent.push({ url: new URL(raw), method: init?.method ?? 'GET', body }); + return new Response(JSON.stringify({ id: 'org_new', name: body.name, slug: body.slug }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + const client = createAuthClient({ + baseURL: 'http://localhost/api/v1/auth', + fetchFn: fetchFn as unknown as typeof fetch, + }); + const createCalls = () => sent.filter((s) => s.url.pathname.endsWith('/organization/create')); + return { client, createCalls }; +} + +describe('createOrganization sends the zone as a query parameter (objectui#11908)', () => { + it('a zone → POST /organization/create?timezone=Asia%2FShanghai, body exactly { name, slug }', async () => { + const { client, createCalls } = recordingFetch(); + + const org = await client.createOrganization({ name: 'Acme', slug: 'acme', timezone: 'Asia/Shanghai' }); + + expect(org.id).toBe('org_new'); + const calls = createCalls(); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('POST'); + expect(calls[0].url.pathname).toBe('/api/v1/auth/organization/create'); + expect(calls[0].url.search).toBe('?timezone=Asia%2FShanghai'); + expect(calls[0].url.searchParams.get('timezone')).toBe('Asia/Shanghai'); + expect(Object.keys(calls[0].body).sort()).toEqual(['name', 'slug']); + expect(calls[0].body).toEqual({ name: 'Acme', slug: 'acme' }); + }); + + it('a logo still rides the body; the zone still rides the query only', async () => { + const { client, createCalls } = recordingFetch(); + + await client.createOrganization({ name: 'Acme', slug: 'acme', logo: 'https://x.test/l.png', timezone: 'Europe/Paris' }); + + const [call] = createCalls(); + expect(call.url.searchParams.get('timezone')).toBe('Europe/Paris'); + expect(Object.keys(call.body).sort()).toEqual(['logo', 'name', 'slug']); + }); + + it.each([ + ['omitted', undefined], + ['empty', ''], + ] as const)('control: a zone %s → no timezone parameter at all, body exactly { name, slug }', async (_label, timezone) => { + const { client, createCalls } = recordingFetch(); + + await client.createOrganization( + timezone === undefined ? { name: 'Acme', slug: 'acme' } : { name: 'Acme', slug: 'acme', timezone }, + ); + + const calls = createCalls(); + expect(calls).toHaveLength(1); + expect(calls[0].url.search).toBe(''); + expect(calls[0].url.searchParams.has('timezone')).toBe(false); + expect(Object.keys(calls[0].body).sort()).toEqual(['name', 'slug']); + }); +}); diff --git a/packages/auth/src/createAuthClient.ts b/packages/auth/src/createAuthClient.ts index 24f1a2607f..631e7ae6c6 100644 --- a/packages/auth/src/createAuthClient.ts +++ b/packages/auth/src/createAuthClient.ts @@ -822,11 +822,18 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { return (data ?? []) as AuthOrganization[]; }, - async createOrganization(orgData: { name: string; slug: string; logo?: string }): Promise { + async createOrganization(orgData: { name: string; slug: string; logo?: string; timezone?: string }): Promise { + // objectui#11908: the creator's zone rides the QUERY (`?timezone=`), the + // shape the server's create route reads it from; the body stays + // `{ name, slug, logo }`. better-auth's client proxy lifts + // `fetchOptions.query` onto the URL and keeps it out of the body. An + // absent or empty zone sends no parameter at all. + const { timezone } = orgData; const { data, error } = await (betterAuth as any).organization.create({ name: orgData.name, slug: orgData.slug, logo: orgData.logo, + ...(timezone ? { fetchOptions: { query: { timezone } } } : {}), }); if (error) throw toAuthError(error, 'Failed to create organization'); return data as unknown as AuthOrganization; diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 4ed8068ca8..251bc188b6 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -383,8 +383,16 @@ export interface AuthClient { /** List organizations the current user belongs to */ listOrganizations: () => Promise; - /** Create a new organization */ - createOrganization: (data: { name: string; slug: string; logo?: string }) => Promise; + /** + * Create a new organization. + * + * `timezone` (objectui#11908) is the creator's IANA zone, for example the + * browser's `Intl.DateTimeFormat().resolvedOptions().timeZone`. It travels + * as the `timezone` QUERY parameter of `POST /organization/create`, never in + * the body, which stays `{ name, slug, logo }`. Omitted or empty, nothing is + * sent and the server keeps its default zone; the server judges the value. + */ + createOrganization: (data: { name: string; slug: string; logo?: string; timezone?: string }) => Promise; /** Set the active organization for the current session */ setActiveOrganization: (orgId: string) => Promise; /** Get the full active organization object */ From 869ccd28713866ea9bdc9f447f33bb626d7eed5f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:21:44 +0000 Subject: [PATCH 2/4] feat(console): both workspace-creation call sites send the creator's browser zone (objectui#11908) `CreateWorkspaceDialog` (every slug attempt) and `SetupPage`'s create branch pass the browser's `Intl.DateTimeFormat().resolvedOptions().timeZone` as `createOrganization`'s `timezone`. A missing, empty or throwing read sends no key at all. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- apps/console/src/pages/auth/SetupPage.tsx | 6 + .../setupCreateTimezone-11908.test.tsx | 125 ++++++++++++++++++ .../organizations/CreateWorkspaceDialog.tsx | 31 ++++- .../__tests__/CreateWorkspaceDialog.test.tsx | 80 ++++++++++- 4 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 apps/console/src/pages/auth/__tests__/setupCreateTimezone-11908.test.tsx diff --git a/apps/console/src/pages/auth/SetupPage.tsx b/apps/console/src/pages/auth/SetupPage.tsx index ee8e44ae1d..18ccaaa346 100644 --- a/apps/console/src/pages/auth/SetupPage.tsx +++ b/apps/console/src/pages/auth/SetupPage.tsx @@ -25,6 +25,7 @@ import { Label, } from '@object-ui/components'; import { withConsoleBase } from '../../utils/consoleBase'; +import { browserTimeZone } from '../settings/workspaceTimezonePrompt'; const AUTH_BASE = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; @@ -159,9 +160,14 @@ export function SetupPage() { }); activeOrgId = personal.id; } else { + // objectui#11908: a workspace created here takes the creator's + // browser zone, so its first administrator is not asked for one. + // No zone, no key: the server keeps its default. + const timezone = browserTimeZone(); const created = await createOrganization({ name: trimmedName, slug: slug || `org-${Date.now().toString(36)}`, + ...(timezone ? { timezone } : {}), }); activeOrgId = created?.id; } diff --git a/apps/console/src/pages/auth/__tests__/setupCreateTimezone-11908.test.tsx b/apps/console/src/pages/auth/__tests__/setupCreateTimezone-11908.test.tsx new file mode 100644 index 0000000000..dad7782d62 --- /dev/null +++ b/apps/console/src/pages/auth/__tests__/setupCreateTimezone-11908.test.tsx @@ -0,0 +1,125 @@ +/** + * objectui#11908 — when the first-run wizard CREATES the owner's workspace, the + * create call carries the creator's browser zone. + * + * `SetupPage` normally renames the organization the server bootstrapped at + * sign-up; it creates one only when no bootstrap organization appears after + * its four `refreshOrganizations()` attempts. That branch is a workspace + * creation like the console's create dialog, so it sends `timezone` the same + * way: the browser's zone, read by `browserTimeZone()`, and no key at all when + * the browser reports none. `@object-ui/auth` puts the value on the create + * route's query (`createOrganization-timezone-11908.test.ts` pins the wire). + * + * The zone is stubbed at its reader, + * `Intl.DateTimeFormat.prototype.resolvedOptions`. The poll's 500 ms pauses + * run on fake `setTimeout` from the submit on, so the branch is reached + * without waiting in real time. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; + +let authState: Record; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => authState, +})); + +vi.mock('sonner', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, toast: { success: vi.fn(), error: vi.fn() } }; +}); + +const { SetupPage } = await import('../SetupPage'); + +const BROWSER_ZONE = 'Asia/Shanghai'; +const realResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions; +let browserZone: string | undefined = BROWSER_ZONE; +let zoneReader: MockInstance; +let createOrganization: ReturnType; + +beforeEach(() => { + browserZone = BROWSER_ZONE; + zoneReader = vi + .spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions') + .mockImplementation(function (this: Intl.DateTimeFormat) { + return { ...realResolvedOptions.call(this), timeZone: browserZone as string }; + }); + vi.spyOn(window.location, 'assign').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // A fresh deployment: the bootstrap probe reports no owner, so the wizard renders. + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => ({ hasOwner: false }) })), + ); + createOrganization = vi.fn(async () => ({ id: 'org_new' })); + authState = { + user: null, + signUp: vi.fn(async () => undefined), + // No bootstrap organization ever appears: the wizard takes the create branch. + refreshOrganizations: vi.fn(async () => []), + updateOrganization: vi.fn(async () => undefined), + createOrganization, + switchOrganization: vi.fn(async () => undefined), + }; +}); + +afterEach(() => { + vi.useRealTimers(); + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** Fill the wizard, submit, and run the poll's pauses to the create call. */ +async function createThroughTheWizard(arrangeZone?: () => void) { + render( + + + + + , + ); + fireEvent.change(await screen.findByLabelText('Your name'), { target: { value: 'Ada' } }); + fireEvent.change(screen.getByLabelText('Organization name'), { target: { value: 'Acme Inc.' } }); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'ada@example.com' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'hunter2hunter2' } }); + arrangeZone?.(); + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const submit = screen.getByRole('button', { name: 'Create owner account' }); + fireEvent.submit(submit.closest('form') as HTMLFormElement); + await vi.advanceTimersByTimeAsync(2000); +} + +describe("SetupPage's create branch sends the creator's browser zone (objectui#11908)", () => { + it('a browser zone → the create call carries it as timezone, beside name and slug only', async () => { + await createThroughTheWizard(); + + expect(createOrganization).toHaveBeenCalledTimes(1); + expect(createOrganization.mock.calls[0][0]).toStrictEqual({ + name: 'Acme Inc.', + slug: 'acme-inc', + timezone: 'Asia/Shanghai', + }); + expect(authState.updateOrganization).not.toHaveBeenCalled(); + }); + + it.each([ + ['reports no zone', () => { browserZone = undefined; }], + ['reports an empty zone', () => { browserZone = ''; }], + ['throws on the read', () => { + zoneReader.mockImplementation(() => { + throw new RangeError('no time zone data'); + }); + }], + ] as const)('control: a browser that %s → no timezone key at all', async (_label, arrange) => { + await createThroughTheWizard(arrange); + + expect(createOrganization).toHaveBeenCalledTimes(1); + const sent = createOrganization.mock.calls[0][0] as Record; + expect(Object.keys(sent).sort()).toEqual(['name', 'slug']); + }); +}); diff --git a/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx b/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx index 848eb88003..f102cb5d50 100644 --- a/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx +++ b/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx @@ -90,21 +90,39 @@ function suffixedSlug(base: string): string { return `${base.slice(0, 43).replace(/-+$/, '')}-${suffix}`; } +/** + * The creator's browser zone, or `undefined` when the browser reports none + * (objectui#11908). A new workspace takes this zone at creation, so its first + * administrator is not asked for a zone the browser already knows. The same + * read as the console's `browserTimeZone()`, which this package cannot import; + * the server judges the value and keeps its default for one it refuses. + */ +function browserTimeZone(): string | undefined { + try { + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return typeof zone === 'string' && zone.length > 0 ? zone : undefined; + } catch { + return undefined; + } +} + /** * Create the organization under the generated slug, retrying a slug collision * with a suffixed variant (at most `SLUG_ATTEMPTS` calls in all). The user * cannot see or edit the slug in this dialog, so a collision is not theirs to - * resolve; any other refusal is rethrown unchanged. + * resolve; any other refusal is rethrown unchanged. Every attempt carries the + * same `timezone`, and none carries the key when there is no zone. */ async function createWithGeneratedSlug( - create: (data: { name: string; slug: string }) => Promise, + create: (data: { name: string; slug: string; timezone?: string }) => Promise, name: string, slug: string, + timezone: string | undefined, ): Promise { let attemptSlug = slug; for (let attempt = 1; ; attempt++) { try { - return await create({ name, slug: attemptSlug }); + return await create({ name, slug: attemptSlug, ...(timezone ? { timezone } : {}) }); } catch (err) { if (attempt >= SLUG_ATTEMPTS || !isSlugTaken(err)) throw err; attemptSlug = suffixedSlug(slug); @@ -180,7 +198,12 @@ export function CreateWorkspaceDialog({ setError(null); try { - const org = await createWithGeneratedSlug(createOrganization, name.trim(), slug.trim()); + const org = await createWithGeneratedSlug( + createOrganization, + name.trim(), + slug.trim(), + browserTimeZone(), + ); // Born-with-env: eagerly ensure the new org's production environment so // the user lands in a ready workspace with no onboarding-wizard detour. // `createOrganization` already switched the active org; we also pass diff --git a/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx b/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx index 15413c796b..1ca9020844 100644 --- a/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx +++ b/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx @@ -12,10 +12,15 @@ * 3. best-effort — when eager provisioning throws, the user is still * landed (`onCreated`) so the lazy onboarding gate can * provision the env on first navigation. + * + * The browser's zone is stubbed at its reader, + * `Intl.DateTimeFormat.prototype.resolvedOptions`, to `Asia/Shanghai`, so the + * create call's argument does not depend on the zone of the machine running + * the suite (objectui#11908). */ import '@testing-library/jest-dom/vitest'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { CreateWorkspaceDialog } from '../CreateWorkspaceDialog'; import { provisionProductionEnvironment } from '../provisionEnvironment'; @@ -42,11 +47,27 @@ const provisionMock = vi.mocked(provisionProductionEnvironment); const NEW_ORG = { id: 'org-123', name: 'Acme Inc', slug: 'acme-inc' }; +const BROWSER_ZONE = 'Asia/Shanghai'; +const realResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions; +/** What the stubbed reader answers; `undefined` is a browser that reports no zone. */ +let browserZone: string | undefined = BROWSER_ZONE; +let zoneReader: MockInstance; + beforeEach(() => { vi.clearAllMocks(); getAuthConfig.mockResolvedValue({ features: { multiOrgEnabled: true } }); createOrganization.mockResolvedValue(NEW_ORG); provisionMock.mockResolvedValue({ id: 'env-1', hostname: 'os-abc123.objectstack.app' }); + browserZone = BROWSER_ZONE; + zoneReader = vi + .spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions') + .mockImplementation(function (this: Intl.DateTimeFormat) { + return { ...realResolvedOptions.call(this), timeZone: browserZone as string }; + }); +}); + +afterEach(() => { + zoneReader.mockRestore(); }); /** Let the `getAuthConfig().then(...)` effect settle (sets `multiOrgDisabled`). */ @@ -74,7 +95,11 @@ describe('CreateWorkspaceDialog', () => { fillAndSubmit(); await waitFor(() => - expect(createOrganization).toHaveBeenCalledWith({ name: 'Acme Inc', slug: 'acme-inc' }), + expect(createOrganization).toHaveBeenCalledWith({ + name: 'Acme Inc', + slug: 'acme-inc', + timezone: BROWSER_ZONE, + }), ); await waitFor(() => // The workspace name is passed through as the production env displayName (#2228). @@ -171,8 +196,15 @@ describe('CreateWorkspaceDialog', () => { expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ id: 'org-123' })), ); expect(createOrganization).toHaveBeenCalledTimes(2); - expect(createOrganization.mock.calls[0][0]).toEqual({ name: 'Acme Inc', slug: 'acme-inc' }); - expect((createOrganization.mock.calls[1][0] as { slug: string }).slug).toMatch(/^acme-inc-[0-9a-z]{4}$/); + expect(createOrganization.mock.calls[0][0]).toEqual({ + name: 'Acme Inc', + slug: 'acme-inc', + timezone: BROWSER_ZONE, + }); + const retry = createOrganization.mock.calls[1][0] as { slug: string; timezone?: string }; + expect(retry.slug).toMatch(/^acme-inc-[0-9a-z]{4}$/); + // The retry is the same creation: it carries the same zone (objectui#11908). + expect(retry.timezone).toBe(BROWSER_ZONE); expect(screen.queryByTestId('workspace-create-error')).toBeNull(); }); @@ -190,4 +222,44 @@ describe('CreateWorkspaceDialog', () => { expect(provisionMock).not.toHaveBeenCalled(); }); }); + + // objectui#11908: a new workspace takes the creator's browser zone at + // creation, so its first administrator is not asked for one. The zone rides + // the create call as `timezone`; `@object-ui/auth` puts it on the query + // (`createOrganization-timezone-11908.test.ts` pins the wire). + describe("sends the creator's browser zone (objectui#11908)", () => { + it('a browser zone → the create call carries it as timezone, beside name and slug only', async () => { + render( {}} />); + await settleAuthConfig(); + + fillAndSubmit(); + + await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1)); + expect(createOrganization.mock.calls[0][0]).toStrictEqual({ + name: 'Acme Inc', + slug: 'acme-inc', + timezone: 'Asia/Shanghai', + }); + }); + + it.each([ + ['reports no zone', () => { browserZone = undefined; }], + ['reports an empty zone', () => { browserZone = ''; }], + ['throws on the read', () => { + zoneReader.mockImplementation(() => { + throw new RangeError('no time zone data'); + }); + }], + ] as const)('control: a browser that %s → no timezone key at all', async (_label, arrange) => { + arrange(); + render( {}} />); + await settleAuthConfig(); + + fillAndSubmit(); + + await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1)); + const sent = createOrganization.mock.calls[0][0] as Record; + expect(Object.keys(sent).sort()).toEqual(['name', 'slug']); + }); + }); }); From 5bf108fa9b4d4da2a51dcf495851067b5f218d3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:25:55 +0000 Subject: [PATCH 3/4] chore(changeset): declare the createOrganization timezone widening (objectui#11908) `@object-ui/auth` minor (an additive optional member on two published declarations); `@object-ui/app-shell` and `@object-ui/console` patch. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11908-create-workspace-timezone.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/11908-create-workspace-timezone.md diff --git a/.changeset/11908-create-workspace-timezone.md b/.changeset/11908-create-workspace-timezone.md new file mode 100644 index 0000000000..5edfbcdf1a --- /dev/null +++ b/.changeset/11908-create-workspace-timezone.md @@ -0,0 +1,11 @@ +--- +'@object-ui/auth': minor +'@object-ui/app-shell': patch +'@object-ui/console': patch +--- + +A new workspace now takes its creator's browser timezone when it is created, so its first administrator is no longer asked for a zone the browser already knows (objectui#11908). + +`createOrganization` accepts an optional `timezone`, an IANA zone such as `Asia/Shanghai`, on both `AuthClient` and the `useAuth()` context. The client sends it as the `timezone` query parameter of `POST /organization/create`; the request body stays `{ name, slug }` (plus `logo` when given). When the zone is absent or empty, no parameter is sent and the server keeps its default zone. A server that does not read the parameter ignores it. + +The console's create-workspace dialog and the first-run setup page's create branch both pass the browser's `Intl.DateTimeFormat().resolvedOptions().timeZone`. A browser that reports no zone sends none. Existing workspaces are unchanged, and the one-time timezone prompt still asks about a workspace whose zone is still the default. From 7f443ba308ff9c7951ab1ad650cd7f81732cd5b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:02:45 +0000 Subject: [PATCH 4/4] test(i18n): declare the create dialog's host time-zone read in the machine-locale census (objectui#11908) `CreateWorkspaceDialog` reads `Intl.DateTimeFormat().resolvedOptions().timeZone` to seed a new workspace's zone. That is the host zone, the one member of `resolvedOptions()` the locale does not decide, and nothing is formatted: the same verdict the census already gives the console's prompt reader, which `packages/app-shell` cannot import. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../i18n/src/__tests__/machineLocaleCensus-9909.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts index 635ef5b48e..ccd30a3ae3 100644 --- a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts +++ b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts @@ -257,6 +257,15 @@ const DECLARED: Exemption[] = [ '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/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx', + expression: 'const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;', + count: 1, + verdict: 'not applicable', + reason: + 'reads the HOST time zone a new workspace is seeded with at creation (objectui#11908), 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()}',