Skip to content
Closed
30 changes: 30 additions & 0 deletions .changeset/30206-first-run-checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@object-ui/console': patch
'@object-ui/i18n': patch
---

feat(console)!: an administrator's first-run checklist on Home replaces the currency and time zone dialog, and a country typed by name is saved as its code (#30206)

Clause-②: yes (narrowing) — the published `@object-ui/i18n` packs (all ten) drop the `console.workspaceTimezonePrompt.*` keys, which had no reader outside the removed dialog. The widening half: new console i18n keys `console.firstRunChecklist.*` and `console.settingsView.invalidFormat` / `.invalidFormatHelp` / `.invalidFormatPick` (all ten packs).
Metadata-protocol: none

<!-- adr-0087: not-required (runtime-interface-only ui/packages/i18n/src/locales/en.ts#TranslationKeys) the key type of the console's own translation catalogue: no Zod schema, no spec declaration and no stored metadata reads it, so the compiler is the channel that reaches every caller -->

The dialog that asked for the system timezone and default currency opened over the first page an administrator visited, before anything needed a currency, and once covered the toast that said how to share an invitation. It is gone. The same questions, and the other things a new workspace needs, are now rows of a checklist at the top of Home (`/home`):

1. **Organization name**: done once the organization no longer carries the name the platform gave it ("Default Organization"). Links to Setup → Organization.
2. **Default language and country**, 3. **System timezone**, 4. **Default currency**: done once the setting is set (for language, country and timezone, set by someone rather than left at the platform default; for the currency, which has no default, holding any value). Each opens in place with the Settings page's own fields, pre-filled from the browser: language, country and currency from the browser's first language (so `zh-CN` offers 中文, China and CNY together), the timezone from the browser's zone. Nothing is written until the row's Confirm, which saves through the Settings page's own `PUT /api/settings/localization`.
5. **Email delivery**: done once mail can deliver (a provider other than `log`, a From address, and a host for SMTP). Shown only to a session that may write mail. Links to Setup → Mail Delivery.
6. **Invite colleagues**: done once there is a second member or a pending invitation. Links to Setup → Users.

Each row's state is read from the real setting every time, never from a click. The checklist is shown only to a session that may write `localization`; a member sees nothing, and a member's Home sends no settings namespace read. It draws nothing once every row is done, and its close button hides it for that administrator in that workspace on that device, writing nothing to the server. The rules the dialog was built on still hold: the browser's zone and currency are never written without the administrator's Confirm, and an unset currency stays on the list until it is set.

**BREAKING** (`@object-ui/i18n`, TypeScript only): the exported `TranslationKeys` (`typeof en`) loses the 14 `console.workspaceTimezonePrompt.*` keys, in all ten packs (ar, de, en, es, fr, ja, ko, pt, ru, zh). A `t()` call that names one still compiles, since its key is a `string`, and reads the key back. Nothing replaces them one for one; the fix is to delete the reference.

| Removed key | Replacement |
|:---|:---|
| `console.workspaceTimezonePrompt.title`, `.description`, `.laterHint`, `.decline`, `.confirm`, `.saved`, `.notNow` | none: the dialog is retired; the timezone is a row of `console.firstRunChecklist` (`.timezone.*`) |
| `console.workspaceTimezonePrompt.currencyTitle`, `.currencyDescription`, `.currencyConfirm`, `.currencySaved` | none: the currency is a row of `console.firstRunChecklist` (`.currency.*`) |
| `console.workspaceTimezonePrompt.bothTitle`, `.bothConfirm`, `.bothSaved` | none: the checklist confirms each row on its own |

Typing a country's name into a country picker now saves its code: 「中国」 typed into 默认国家, whose option reads 「中国 (CN)」, saves `CN` instead of being refused by the server's two-letter format check. Only a name exactly one option carries counts; any other text is sent as typed, as before. When the server does refuse a value for its format (`invalid_format`), the Settings page and the checklist now say so in the reader's language, naming the setting by its label: "Choose a value for Default country from the list." for a picker, and "Gateway model is not in a valid format. Use provider/model." (with the field's own help line) for a typed field. Every other refusal keeps the server's sentence.
23 changes: 12 additions & 11 deletions docs/qa/platform-checklist/RUNNER.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,18 @@ contradicts it, and correct it here when it does.
read-only" assumption: the pair is the door, and the read-only-package lock the
console renders is a *different* gate (see `access-security.readonly-package-locks-studio`).

- **A fresh organization's first page opens under a one-time prompt: "Set the default
currency", or the system timezone, or both in one dialog.** An administrator is asked
once while `localization.timezone` is still the manifest default or the organization
has no default currency (`ui/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx`,
`workspaceTimezonePromptLogic.ts`), so on a fresh DB the dialog
(`data-testid="workspace-timezone-prompt"`) sits over the first page the admin opens,
Studio and app pages included (QA runs #29890 and #29893). Dismiss it with **Not now**
(Keep the default when only the timezone is asked), `data-testid="workspace-timezone-prompt-decline"`,
before any screenshot or DOM read. The answer is remembered per organization and user in
that browser's `localStorage` (`os:workspace-timezone-prompt:<org>:<user>`), so a new
browser context asks again. ⛔ Do not score a clause FAIL for chrome hidden under it.
- **A fresh organization's Home carries a first-run checklist for the administrator; no
dialog opens over any page.** Until objectstack#30206 a one-time "Set the default
currency" / system timezone dialog sat over the first page an administrator opened. It
is gone: the same questions are rows of a card at the top of `/home`
(`ui/apps/console/src/pages/settings/FirstRunChecklist.tsx`,
`data-testid="first-run-checklist"`), shown only to a session that may write
`localization`, and it writes nothing until a row's own Confirm. Studio, Setup and app
pages open with nothing in front of them. A screenshot of Home on a fresh DB shows the
card above the page's sections; its close button
(`data-testid="first-run-checklist-dismiss"`) hides it for that organization and user in
that browser's `localStorage` (`os:first-run-checklist:<org>:<user>`). ⛔ Do not score a
Home clause FAIL because the card pushes the page's sections down.

- **`objectstack verify --rls` is a separate invocation — bare `verify` prints no RLS
section at all.** `runRlsProofs` runs only behind the flag
Expand Down
102 changes: 2 additions & 100 deletions examples/app-showcase/e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { chromium, request, type FullConfig, type LaunchOptions } from '@playwright/test';
import { request } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';

Expand All @@ -19,9 +19,8 @@ const API = process.env.SMOKE_API_URL || 'http://localhost:3000';
const EMAIL = process.env.SMOKE_EMAIL || 'admin@objectos.ai';
const PASSWORD = process.env.SMOKE_PASSWORD || 'admin123';
const STATE_PATH = 'e2e/.auth/state.json';
const APP = process.env.SHOWCASE_APP || 'com.example.showcase';

export default async function globalSetup(config: FullConfig) {
export default async function globalSetup() {
const ctx = await request.newContext();
const res = await ctx.post(`${API}/api/v1/auth/sign-in/email`, {
data: { email: EMAIL, password: PASSWORD },
Expand All @@ -38,102 +37,5 @@ export default async function globalSetup(config: FullConfig) {
};
mkdirSync(dirname(STATE_PATH), { recursive: true });
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));

// The specs' own launch options (playwright.config.ts carries
// `OS_TEST_CHROMIUM_EXECUTABLE_PATH` there), so the prompt is answered in the
// browser the specs run in.
const promptRecord = await declineWorkspacePrompt(token, config.projects[0]?.use?.launchOptions);
if (promptRecord.length > 0) {
state.origins[0].localStorage.push(...promptRecord);
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
}
}

/**
* Decline the console's one-time workspace prompt before any spec runs.
*
* The console asks an administrator, in ONE modal dialog on the first app page
* it renders, to set the workspace timezone while it is still the manifest
* default, and — since the prompt's currency arm (objectstack#28872, commit
* 2ac698baf1) — the default currency while `localization.currency` has no
* value. The showcase's dev seed derives the currency from the host's POSIX
* locale and leaves it unset on a host that sets none or names no country, and
* the seeded admin may write settings, so every spec would meet that dialog in
* front of the page it reads: the modal makes the page behind it
* `aria-hidden`, so role queries (`getByRole('checkbox')`, `getByRole('table')`,
* a section's disclosure button) find nothing and a click never lands.
*
* The run answers it ONCE, in a real browser, through the prompt's own decline
* button ("Not now" / "Keep the default"), the same way the console's own live
* suite does (ui/e2e/live/global-setup.ts). Declining writes nothing to the
* workspace — the showcase stays exactly as seeded, so no spec's reading of an
* amount moves — and the prompt keeps its per-device record of having asked in
* this origin's localStorage (`PROMPT_RECORD_PREFIX`:ORG:USER). That record,
* and nothing else the console stored while it was open, is what this returns
* for the storageState every spec reuses: the rest (the sidebar's wide-open
* flag, recent items, the active organization) would change the fresh-context
* layout the specs measure from — detail-shapes derives its 780 px window from
* a context with no stored sidebar state.
*
* Skipped when the backend says the prompt would not ask (the console's own
* condition per key, `workspaceTimezonePromptLogic.ts`). A prompt that was
* expected but never shown is reported, not failed: the specs then show whether
* anything stands in their way. A prompt that was declined but left no record
* throws: every spec would meet it again, and that is the run's one cause.
*/
const PROMPT_RECORD_PREFIX = 'os:workspace-timezone-prompt:';

async function declineWorkspacePrompt(
token: string,
launchOptions: LaunchOptions | undefined,
): Promise<{ name: string; value: string }[]> {
const api = await request.newContext({ extraHTTPHeaders: { Authorization: `Bearer ${token}` } });
let wouldAsk = false;
try {
const res = await api.get(`${API}/api/settings/localization`);
if (res.ok()) {
const body = await res.json();
const values = (body?.data ?? body)?.values;
const timezone = values?.timezone;
const currency = values?.currency;
const timezoneUnset = timezone?.source === 'default' && !timezone?.locked;
const currencyUnset =
currency?.source === 'default' &&
!currency?.locked &&
(currency?.value === null || currency?.value === undefined || currency?.value === '');
wouldAsk = timezoneUnset || currencyUnset;
}
} finally {
await api.dispose();
}
if (!wouldAsk) return [];

const browser = await chromium.launch(launchOptions);
try {
const context = await browser.newContext({ storageState: STATE_PATH, baseURL: API });
const page = await context.newPage();
await page.goto(`/_console/apps/${APP}`, { waitUntil: 'domcontentloaded' });
const prompt = page.getByTestId('workspace-timezone-prompt');
try {
await prompt.waitFor({ state: 'visible', timeout: 60_000 });
} catch {
console.warn('[showcase-smoke] the workspace timezone or currency is unset, but the console showed no prompt');
return [];
}
await page.getByTestId('workspace-timezone-prompt-decline').click();
await prompt.waitFor({ state: 'hidden' });
const stored = await context.storageState();
const record = (stored.origins.find((o) => o.origin === new URL(API).origin)?.localStorage ?? []).filter((e) =>
e.name.startsWith(PROMPT_RECORD_PREFIX),
);
if (record.length === 0) {
throw new Error(
`the workspace prompt was declined but left no ${PROMPT_RECORD_PREFIX}* record in localStorage, ` +
'so every spec would meet it again',
);
}
return record;
} finally {
await browser.close();
}
}
10 changes: 9 additions & 1 deletion ui/apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
} from '@object-ui/app-shell';

import { AppContent, ConsolePermissionsRequest, ConsolePermissionsScope } from './AppContent';
import { FirstRunChecklist } from './pages/settings/FirstRunChecklist';
import { publishAuthState } from './i18nSession';
import { FaviconSync } from './components/FaviconSync';
import { RootLandingRedirect } from './components/RootLandingRedirect';
Expand Down Expand Up @@ -194,11 +195,18 @@ function NativeShellRouting() {
return null;
}

/** Wraps `DefaultHomeLayout` so the FAB gets the signed-in user id. */
/**
* Wraps `DefaultHomeLayout` so the FAB gets the signed-in user id, and draws
* an administrator's first-run checklist above the page's own sections
* (objectstack#30206). The route sits under `ConsolePermissionsScope`, which
* is what tells the checklist whether this session may write the settings it
* lists; for anyone else it draws nothing.
*/
function HomeRoute() {
const { user } = useAuth();
return (
<DefaultHomeLayout userId={user?.id}>
<FirstRunChecklist />
<DefaultHomePage />
</DefaultHomeLayout>
);
Expand Down
6 changes: 0 additions & 6 deletions ui/apps/console/src/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { createAuthenticatedFetch, useAuth } from '@object-ui/auth';
import type { DataSource } from '@object-ui/types';
import { LocalizationFetchProvider } from './LocalizationFetchProvider';
import { InAppFormRoute } from './components/InternalFormRoute';
import { WorkspaceTimezonePrompt } from './pages/settings/WorkspaceTimezonePrompt';
import { APPROVAL_ACTION_OBJECT, APPROVAL_REQUEST_OBJECT, routeApprovalRecordReads } from './services/approvalRequestsDataSource';

const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage })));
Expand Down Expand Up @@ -528,11 +527,6 @@ export function AppContent() {
/>
</ApprovalRecordReads>
</LocalizationFetchProvider>
{/* objectui#11758 — asks an administrator once to set a still-default
workspace timezone. Under `ConsolePermissionsScope`, because that
provider is what answers whether this session may write the
setting; mounted once for the whole app shell, beside the routes. */}
<WorkspaceTimezonePrompt />
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ vi.mock('../../../../packages/app-shell/src/hooks/useAiSurface', async (importOr
vi.mock('../LocalizationFetchProvider', () => ({
LocalizationFetchProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({ WorkspaceTimezonePrompt: () => null }));
vi.mock('../pages/settings/FirstRunChecklist', () => ({ FirstRunChecklist: () => null }));
vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: () => null }));
vi.mock('../components/SetupRoute', () => ({ SetupRoute: () => null }));
vi.mock('../components/FormPage', () => ({ FormPage: () => null }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ vi.mock('@object-ui/app-shell', async (importOriginal) => {
vi.mock('../LocalizationFetchProvider', () => ({
LocalizationFetchProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({ WorkspaceTimezonePrompt: () => null }));
vi.mock('../pages/settings/FirstRunChecklist', () => ({ FirstRunChecklist: () => null }));
vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: () => null }));
vi.mock('../components/SetupRoute', () => ({ SetupRoute: () => null }));
vi.mock('../components/FormPage', () => ({ FormPage: () => null }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@ vi.mock('@object-ui/permissions', async (importOriginal) => ({
vi.mock('../LocalizationFetchProvider', () => ({
LocalizationFetchProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({
WorkspaceTimezonePrompt: () => null,
}));

import { AppContent } from '../AppContent';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ vi.mock('../../../../packages/app-shell/src/hooks/useAiSurface', async (importOr
vi.mock('../LocalizationFetchProvider', () => ({
LocalizationFetchProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({ WorkspaceTimezonePrompt: () => null }));
vi.mock('../pages/settings/FirstRunChecklist', () => ({ FirstRunChecklist: () => null }));
vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: () => null }));
vi.mock('../components/SetupRoute', () => ({ SetupRoute: () => null }));
vi.mock('../components/FormPage', () => ({ FormPage: () => <GrantProbe testId="public-form" /> }));
Expand Down
6 changes: 3 additions & 3 deletions ui/apps/console/src/loadLocales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ let bootPainted: Promise<void> | null = null;
* page load, so every reader that waits on it is released by the same paint.
*
* Shared by the console's two readers whose answer no first-paint surface
* needs, this one and `WorkspaceTimezonePrompt`'s settings read. It waits on
* the splash's DOM marker rather than on any one component, because both are
* mounted beside the routes, under the splash. A tab opened in the background
* needs, this one and `FirstRunChecklist`'s settings read. It waits on the
* splash's DOM marker rather than on any one component, because neither is
* the surface the splash waits for. A tab opened in the background
* paints no frame, so it resolves when the tab is first shown.
*/
export function whenBootPainted(): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion ui/apps/console/src/pages/auth/SetupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
Label,
} from '@object-ui/components';
import { withConsoleBase } from '../../utils/consoleBase';
import { browserTimeZone } from '../settings/workspaceTimezonePromptLogic';
import { browserTimeZone } from '../settings/firstRunChecklistLogic';

const AUTH_BASE = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`;

Expand Down
Loading
Loading