Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/11908-create-workspace-timezone.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions apps/console/src/pages/auth/SetupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => authState,
}));

vi.mock('sonner', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
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<typeof vi.fn>;

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(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<MemoryRouter initialEntries={['/setup']}>
<SetupPage />
</MemoryRouter>
</I18nProvider>,
);
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<string, unknown>;
expect(Object.keys(sent).sort()).toEqual(['name', 'slug']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthOrganization>,
create: (data: { name: string; slug: string; timezone?: string }) => Promise<AuthOrganization>,
name: string,
slug: string,
timezone: string | undefined,
): Promise<AuthOrganization> {
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);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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`). */
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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();
});

Expand All @@ -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(<CreateWorkspaceDialog open onOpenChange={() => {}} />);
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(<CreateWorkspaceDialog open onOpenChange={() => {}} />);
await settleAuthConfig();

fillAndSubmit();

await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1));
const sent = createOrganization.mock.calls[0][0] as Record<string, unknown>;
expect(Object.keys(sent).sort()).toEqual(['name', 'slug']);
});
});
});
8 changes: 6 additions & 2 deletions packages/auth/src/AuthContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,12 @@ export interface AuthContextValue {
isMembershipResolved: boolean;
/** Switch the active organization (workspace) */
switchOrganization: (orgId: string) => Promise<void>;
/** Create a new organization */
createOrganization: (data: { name: string; slug: string; logo?: string }) => Promise<AuthOrganization>;
/**
* 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<AuthOrganization>;
/**
* Refresh the organizations list. Returns the freshly fetched list so
* callers that need it right away (e.g. the first-run wizard's rename
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,7 @@ export function AuthProvider({
);

const createOrganization = useCallback(
async (data: { name: string; slug: string; logo?: string }): Promise<AuthOrganization> => {
async (data: { name: string; slug: string; logo?: string; timezone?: string }): Promise<AuthOrganization> => {
setError(null);
try {
const org = await client.createOrganization(data);
Expand Down
Loading
Loading