diff --git a/.changeset/11659-console-record-ux.md b/.changeset/11659-console-record-ux.md new file mode 100644 index 0000000000..fea85d89fe --- /dev/null +++ b/.changeset/11659-console-record-ux.md @@ -0,0 +1,19 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +'@object-ui/plugin-detail': patch +--- + +Console, record page and Studio copy from the 2026-10-05 cloud acceptance run (objectui#11659). + +**The cloud home's primary button reads "Open workspace" (zh 「进入工作区」), not "Open Production".** A new customer has exactly one environment, and "production" is control-plane vocabulary. The `cloud:onboarding-next` widget's ready-state button now resolves `cloudOnboarding.openWorkspace`, which replaces `cloudOnboarding.openProduction` in all ten language packs, and the hint under it (`cloudOnboarding.hintReady`) says "workspace" instead of "production environment". The button still navigates to the page's `openProductionUrl`; the page-metadata contract is unchanged. + +**The create-workspace dialog asks for the name only.** `CreateWorkspaceDialog` no longer shows the "URL slug" field: the customer never sees the slug take effect at this step. The slug is still generated from the name, by the same rule as before, and sent with the create call; the owner can change it later in organization settings. Because the slug is no longer the user's to fix in the dialog, a slug collision (`ORGANIZATION_ALREADY_EXISTS` or `ORGANIZATION_SLUG_ALREADY_TAKEN`) is retried with a short random suffix, up to three attempts in all; any other refusal is shown as before. The `workspace.slugLabel` and `workspace.slugHint` pack keys are left in place, now unread. + +**The backup-password reminder no longer appears in the user's first session.** 「建议设置一个备用密码」 showed on the environment home right after a new user built their first app: the reminder was gated on a home-visit count (quiet on the first home mount, shown on the second), and coming back to home after building is the second mount inside the first sitting. It now stays quiet for 12 hours after the first home visit on the device, so it first shows on a later day's visit. The first-visit record (`os:recovery-pw-first-seen`) now holds a timestamp; a device that holds the old `'1'` flag starts the 12 hours over rather than reading it as long ago. When storage is unavailable it stays quiet. `RecoveryPasswordReminder` moves out of `HomePage.tsx` into its own module; it is not exported from the package entry, and its other conditions (dismissed, SSO-enforced, has a local password) are unchanged. + +**The record header's highlight row no longer cuts a phone number.** A drawer's highlight row read 「0574-876」 for the stored `0574-8765-4321`, with no ellipsis. The phone cell draws a dial icon, the number and a copy button in one `inline-flex` box, and the chip's single-line clip cannot put an ellipsis on such a box, so a 9rem chip cut the number mid-way. `HeaderHighlight` now gives a `phone` chip the wide basis that `email` and `url` already take, so a whole number shows; the full value stays in the chip's hover title. Other field types keep their chip width. + +**Studio's dashboard widgets list names each widget's kind in the designer's language.** The list read 「按客户状态统计数量 · bar」: beside each title, `DashboardDefaultInspector` printed the stored `type` id. It now prints the name the add-widget picker shows for that kind (`engine.widgetPicker.type.*`, zh 「柱状图」, en "Bar chart"), with the id on hover. A type the catalogue does not know still prints its id. The stored `type` is unchanged. + +**Studio's automation pillar copy is plain language in zh.** The list heading read 「自动化 · flow」 and the top bar 「默认 OFF · 审阅后再启用」: an internal metadata type and an English switch state inside Chinese copy. They now read 「自动化」 and 「默认停用 · 审阅后再启用」, matching the pillar's own 已启用 / 已停用, and the canvas hint 「可视化编排 · 点选节点配置」 reads 「点选画布上的节点即可配置」. The en heading drops the type too ("Automations"). Whether the pillar is offered on a given plan is not decided here. diff --git a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx b/packages/app-shell/src/console/home/CloudOnboardingNext.tsx index 1bb0c2d179..71733d2b7f 100644 --- a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx +++ b/packages/app-shell/src/console/home/CloudOnboardingNext.tsx @@ -13,13 +13,18 @@ * the same `/cloud/environment-entitlements` endpoint the environment list uses * and renders the right primary action: * - * • has production env → "Open Production" (the doorway into the env, where + * • has production env → "Open workspace" (the doorway into the env, where * building happens) + a "Manage environments" link. + * The label names the place in the customer's words, + * not the operator's: a new customer has exactly one + * environment, and "production" is control-plane + * vocabulary (objectui#11659). The destination is + * still the org's production environment. * • no production env → "Create your environment" (the real first step). * • loading → a neutral skeleton (no CTA flashes / layout jump). * • unknown / error → degrade to BOTH actions, so the button always works. * - * Routes and the "Open Production" endpoint come from the page metadata + * Routes and the "Open workspace" endpoint come from the page metadata * (`properties`) so the Cloud app owns its URLs; this component owns only the * state logic. It mirrors `useEnvironmentEntitlements`' authoritative summary * fetch (org-scoped GET) without its list-only gating. @@ -53,7 +58,7 @@ interface CloudOnboardingNextProps { * Optional backend pre-warm endpoint. When set and the caller already has * a production env, the widget fires a best-effort GET the moment it knows * the env exists — nudging a possibly-asleep container awake WHILE the user - * reads the hint, so the later "Open Production" click lands in an + * reads the hint, so the later "Open workspace" click lands in an * already-waking env instead of paying the full cold-start. No-op when * unset (older page metadata), so this is safe to ship ahead of the page. */ @@ -118,7 +123,7 @@ function useProductionEnvState(warmUrl?: string): Resolved { setState({ phase: 'ready', hasProductionEnv }); // Pre-warm the prod env the instant we know it exists — fire-and-forget, // best-effort, once per resolve. The user is now reading the hint and - // will click "Open Production" seconds later; by then the container is + // will click "Open workspace" seconds later; by then the container is // already waking. The sso-open click warms again, so a failed warm here // costs nothing. Only when the page passes a warmUrl (else no-op). if (hasProductionEnv && warmUrl) { @@ -194,7 +199,7 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) { ) : ( )} - - - - - ); -} - export function HomePage() { const navigate = useNavigate(); const { t } = useObjectTranslation(); diff --git a/packages/app-shell/src/console/home/RecoveryPasswordReminder.tsx b/packages/app-shell/src/console/home/RecoveryPasswordReminder.tsx new file mode 100644 index 0000000000..c71809db37 --- /dev/null +++ b/packages/app-shell/src/console/home/RecoveryPasswordReminder.tsx @@ -0,0 +1,87 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * RecoveryPasswordReminder + * + * Dismissible nudge on the environment home to set a local recovery password — + * shown when the user signed in via SSO and has no local credential yet. We no + * longer force this before the first session (it walled off the magic moment); + * this gentle, one-time reminder preserves instance self-sufficiency without the + * friction. + * + * @module + */ + +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@object-ui/auth'; +import { Button } from '@object-ui/components'; +import { ShieldAlert, X } from 'lucide-react'; +import { + RECOVERY_REMINDER_DISMISSED_KEY, + deviceStorage, + isDismissed, + isPastFirstSession, +} from './recoveryReminderGate.js'; + +export function RecoveryPasswordReminder({ t }: { t: (key: string, opts?: any) => string }) { + const navigate = useNavigate(); + const { hasLocalPassword, getAuthConfig } = useAuth(); + const [show, setShow] = useState(false); + useEffect(() => { + const storage = deviceStorage(); + if (isDismissed(storage)) return; + // Not in the user's first session: let a brand-new SSO user reach their + // magic moment (build their first app) before asking them to set a + // recovery password. See `isPastFirstSession` for why "first session" is + // measured in elapsed time. + if (!isPastFirstSession(storage, Date.now())) return; + let cancelled = false; + Promise.all([ + Promise.resolve(hasLocalPassword?.()), + Promise.resolve(getAuthConfig?.()).catch(() => null), + ]) + .then(([has, config]: [unknown, any]) => { + if (cancelled) return; + // Skip on SSO-enforced envs: password login is disabled there, so a + // recovery password can't be used — nudging for one is misleading and + // gives a false sense of security. Same signal LoginForm uses. + const passwordUnavailable = + config?.features?.ssoEnforced === true || config?.emailPassword?.enabled === false; + if (has === false && !passwordUnavailable) setShow(true); + }) + .catch(() => { /* unknown → don't nag */ }); + return () => { cancelled = true; }; + }, [hasLocalPassword, getAuthConfig]); + const dismiss = () => { + try { localStorage.setItem(RECOVERY_REMINDER_DISMISSED_KEY, '1'); } catch { /* ignore */ } + setShow(false); + }; + if (!show) return null; + return ( +
+
+
+ +

+ {t('home.recoveryReminder.message', { defaultValue: 'Set a recovery password so you can still sign in if single sign-on is ever unavailable.' })} +

+ + +
+
+
+ ); +} diff --git a/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx b/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx index 7e239a3352..0fc7c20d91 100644 --- a/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx @@ -3,7 +3,7 @@ /** * CloudOnboardingNext resolves `hasProductionEnv` from the entitlements summary * and shows the right primary next-step: "Create your environment" when the org - * has none, "Open Production" once it does, and a graceful both-actions fallback + * has none, "Open workspace" once it does, and a graceful both-actions fallback * when the signal can't be resolved. */ @@ -32,9 +32,9 @@ import { CloudOnboardingNext } from '../CloudOnboardingNext'; * these assertions would pass against nothing. Pinned to `en` with browser * detection off so the expectations stay deterministic. */ -const renderOnboarding = (ui: React.ReactElement) => +const renderOnboarding = (ui: React.ReactElement, language: 'en' | 'zh' = 'en') => render( - + {ui} , ); @@ -65,7 +65,7 @@ describe('CloudOnboardingNext', () => { const create = await screen.findByText('Create your environment'); expect(create).toBeTruthy(); - expect(screen.queryByText('Open Production')).toBeNull(); + expect(screen.queryByText('Open workspace')).toBeNull(); fireEvent.click(create); // Deep-links into the create dialog (#844): the environments list consumes @@ -75,21 +75,21 @@ describe('CloudOnboardingNext', () => { ); }); - it('shows "Open Production" once the org has a production env', async () => { + it('shows "Open workspace" once the org has a production env', async () => { fetchImpl = async () => summary(true); renderOnboarding(); - expect(await screen.findByText('Open Production')).toBeTruthy(); + expect(await screen.findByText('Open workspace')).toBeTruthy(); expect(screen.queryByText('Create your environment')).toBeNull(); }); - it('degrades to the open-production actions when the signal cannot be resolved', async () => { + it('degrades to the open-workspace actions when the signal cannot be resolved', async () => { fetchImpl = async () => ({ ok: false, status: 500, json: async () => null }); renderOnboarding(); // Unknown state is fail-safe: it must NEVER strand a real user behind a - // wrong "create" CTA, so it shows Open Production + Manage environments. - expect(await screen.findByText('Open Production')).toBeTruthy(); + // wrong "create" CTA, so it shows Open workspace + Manage environments. + expect(await screen.findByText('Open workspace')).toBeTruthy(); expect(screen.getByText('Manage environments')).toBeTruthy(); expect(screen.queryByText('Create your environment')).toBeNull(); }); @@ -124,22 +124,59 @@ describe('CloudOnboardingNext', () => { }); const { container } = renderOnboarding(); - expect(await screen.findByText('Open Production')).toBeTruthy(); + expect(await screen.findByText('Open workspace')).toBeTruthy(); expect(screen.queryByText('Create your environment')).toBeNull(); expect(container.querySelector('[data-onboarding="unknown"]')).toBeTruthy(); }); + // objectui#11659 ruling 1: the cloud home's primary button names the place in + // the customer's words. A new customer has exactly one environment, and + // "production" is operator vocabulary, so neither the button nor the hint + // under it says it. The destination is unchanged: the same sso-open URL. + describe('the ready state speaks of a workspace, not of production (objectui#11659)', () => { + it('zh: the primary button reads 进入工作区 and the block never says 生产环境', async () => { + fetchImpl = async () => summary(true); + const { container } = renderOnboarding(, 'zh'); + + const open = await screen.findByRole('button', { name: '进入工作区' }); + expect(container.querySelector('[data-onboarding="ready"]')).toBeTruthy(); + expect(container.textContent).not.toContain('生产环境'); + + // Destination unchanged: the button still navigates to the page's sso-open URL. + const assign = vi.fn(); + const original = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...original, set href(v: string) { assign(v); }, get href() { return original.href; } }, + }); + try { + fireEvent.click(open); + } finally { + Object.defineProperty(window, 'location', { configurable: true, value: original }); + } + expect(assign).toHaveBeenCalledWith(PROPS.properties.openProductionUrl); + }); + + it('en: the primary button reads "Open workspace" and the block never says production', async () => { + fetchImpl = async () => summary(true); + const { container } = renderOnboarding(); + + expect(await screen.findByRole('button', { name: 'Open workspace' })).toBeTruthy(); + expect(container.textContent ?? '').not.toMatch(/production/i); + }); + }); + it('renders a non-CTA skeleton while the signal is still loading', async () => { let resolveFetch: (v: any) => void = () => {}; fetchImpl = () => new Promise((r) => { resolveFetch = r; }); const { container } = renderOnboarding(); // Before the fetch resolves: no CTA text, just the skeleton placeholder. - expect(screen.queryByText('Open Production')).toBeNull(); + expect(screen.queryByText('Open workspace')).toBeNull(); expect(screen.queryByText('Create your environment')).toBeNull(); expect(container.querySelector('[data-onboarding="loading"]')).toBeTruthy(); resolveFetch(summary(true)); - await waitFor(() => expect(screen.getByText('Open Production')).toBeTruthy()); + await waitFor(() => expect(screen.getByText('Open workspace')).toBeTruthy()); }); }); diff --git a/packages/app-shell/src/console/home/__tests__/RecoveryPasswordReminder.firstSession-11659.test.tsx b/packages/app-shell/src/console/home/__tests__/RecoveryPasswordReminder.firstSession-11659.test.tsx new file mode 100644 index 0000000000..6c8500b78b --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/RecoveryPasswordReminder.firstSession-11659.test.tsx @@ -0,0 +1,156 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The backup-password reminder does not appear in the user's first session + * (objectui#11659 ruling 3). + * + * The defect the cloud acceptance run measured: a new zh user built their first + * app and came back to the environment home, and 「建议设置一个备用密码」 sat on + * top of 「我的应用」. The reminder used to be gated on a home-VISIT count — the + * first home mount set a flag, the second showed the banner — and "come back to + * home after building" is the second mount inside the first sitting. + * + * The gate now reads elapsed time since the first home visit + * (`isPastFirstSession`, which states why). These cases drive the real + * component over a real `localStorage`, with the clock moved by hand. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen } from '@testing-library/react'; + +const navigateMock = vi.fn(); +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigateMock, +})); + +const hasLocalPassword = vi.fn(); +const getAuthConfig = vi.fn(); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ hasLocalPassword, getAuthConfig }), +})); + +import { RecoveryPasswordReminder } from '../RecoveryPasswordReminder'; +import { + FIRST_SESSION_GRACE_MS, + RECOVERY_REMINDER_DISMISSED_KEY, + RECOVERY_REMINDER_FIRST_SEEN_KEY, + isPastFirstSession, +} from '../recoveryReminderGate'; + +const t = (key: string) => key; +const SIGNUP = Date.UTC(2026, 9, 5, 9, 0, 0); +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; + +/** A plain in-memory Storage double — the gate's whole contract with storage. */ +function memoryStorage(seed: Record = {}) { + const map = new Map(Object.entries(seed)); + return { + getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null), + setItem: (k: string, v: string) => void map.set(k, v), + map, + }; +} + +/** Mount the reminder at `now` and let its auth promises settle. */ +async function mountAt(now: number) { + vi.setSystemTime(now); + const view = render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + return view; +} + +describe('isPastFirstSession (objectui#11659)', () => { + it('records the first home visit and answers false', () => { + const storage = memoryStorage(); + expect(isPastFirstSession(storage, SIGNUP)).toBe(false); + expect(storage.map.get(RECOVERY_REMINDER_FIRST_SEEN_KEY)).toBe(String(SIGNUP)); + }); + + it('stays false for the rest of the first sitting, however many home visits it holds', () => { + const storage = memoryStorage(); + isPastFirstSession(storage, SIGNUP); + for (const later of [1 * MINUTE, 30 * MINUTE, 3 * HOUR, FIRST_SESSION_GRACE_MS - 1]) { + expect(isPastFirstSession(storage, SIGNUP + later)).toBe(false); + } + // The first visit's time is not moved by the later ones. + expect(storage.map.get(RECOVERY_REMINDER_FIRST_SEEN_KEY)).toBe(String(SIGNUP)); + }); + + it('answers true from the next day on', () => { + const storage = memoryStorage({ [RECOVERY_REMINDER_FIRST_SEEN_KEY]: String(SIGNUP) }); + expect(isPastFirstSession(storage, SIGNUP + FIRST_SESSION_GRACE_MS)).toBe(true); + expect(isPastFirstSession(storage, SIGNUP + 24 * HOUR)).toBe(true); + }); + + it('re-records the legacy visit flag instead of reading it as long ago', () => { + const storage = memoryStorage({ [RECOVERY_REMINDER_FIRST_SEEN_KEY]: '1' }); + expect(isPastFirstSession(storage, SIGNUP)).toBe(false); + expect(storage.map.get(RECOVERY_REMINDER_FIRST_SEEN_KEY)).toBe(String(SIGNUP)); + }); + + it('does not nag when it cannot tell (no storage, or storage that throws)', () => { + expect(isPastFirstSession(undefined, SIGNUP)).toBe(false); + const throwing = { + getItem: () => { + throw new Error('SecurityError'); + }, + setItem: () => { + throw new Error('SecurityError'); + }, + }; + expect(isPastFirstSession(throwing, SIGNUP + 48 * HOUR)).toBe(false); + }); +}); + +describe('RecoveryPasswordReminder is quiet in the first session (objectui#11659)', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + localStorage.removeItem(RECOVERY_REMINDER_FIRST_SEEN_KEY); + localStorage.removeItem(RECOVERY_REMINDER_DISMISSED_KEY); + hasLocalPassword.mockReset().mockResolvedValue(false); + getAuthConfig.mockReset().mockResolvedValue({}); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + localStorage.removeItem(RECOVERY_REMINDER_FIRST_SEEN_KEY); + localStorage.removeItem(RECOVERY_REMINDER_DISMISSED_KEY); + }); + + it('home → build the first app → home again: no reminder on either visit', async () => { + const first = await mountAt(SIGNUP); + expect(screen.queryByTestId('recovery-pw-reminder')).toBeNull(); + first.unmount(); + + // The user builds their first app and returns to home in the same sitting. + // This second home mount is where the reminder used to appear. + await mountAt(SIGNUP + 20 * MINUTE); + expect(screen.queryByTestId('recovery-pw-reminder')).toBeNull(); + }); + + it('shows from the next day on, for an SSO user with no local password', async () => { + const first = await mountAt(SIGNUP); + first.unmount(); + + await mountAt(SIGNUP + 20 * HOUR); + expect(screen.getByTestId('recovery-pw-reminder')).toBeInTheDocument(); + expect(screen.getByText('home.recoveryReminder.message')).toBeInTheDocument(); + }); + + it('still never shows to a user who already has a local password', async () => { + hasLocalPassword.mockResolvedValue(true); + const first = await mountAt(SIGNUP); + first.unmount(); + + await mountAt(SIGNUP + 20 * HOUR); + expect(screen.queryByTestId('recovery-pw-reminder')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/console/home/recoveryReminderGate.ts b/packages/app-shell/src/console/home/recoveryReminderGate.ts new file mode 100644 index 0000000000..7c7e286647 --- /dev/null +++ b/packages/app-shell/src/console/home/recoveryReminderGate.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * When the environment home's backup-password reminder may show + * (objectui#11659). Kept apart from `RecoveryPasswordReminder.tsx` so that + * module exports a component only. + * + * @module + */ + +/** Set once the user dismisses the reminder; it never shows again on this device. */ +export const RECOVERY_REMINDER_DISMISSED_KEY = 'os:recovery-pw-dismissed'; + +/** Epoch milliseconds of the user's first home visit on this device. */ +export const RECOVERY_REMINDER_FIRST_SEEN_KEY = 'os:recovery-pw-first-seen'; + +/** + * How long after the first home visit the reminder stays quiet: longer than any + * single sitting, short enough that the next day's visit shows it. + */ +export const FIRST_SESSION_GRACE_MS = 12 * 60 * 60 * 1000; + +/** + * Below this a stored value is not an epoch-milliseconds timestamp (it is + * 2001-09-09). The legacy flag this key used to hold, `'1'`, sits below it. + */ +const EPOCH_MS_FLOOR = 1_000_000_000_000; + +export type ReminderStorage = Pick; + +/** + * Is the user past their FIRST SESSION on this device (objectui#11659)? + * + * The first call records the time of the first home visit and answers `false`; + * later calls answer `true` once `FIRST_SESSION_GRACE_MS` has passed since it. + * + * Why elapsed time, and not a count of home visits or a per-tab marker: + * + * - A visit count is what this replaced, and it is the defect the card + * measured. The flag was set on the first home mount and the reminder shown + * on the second; a new user lands on home, builds their first app, comes back + * to home — the second mount, inside the first sitting — and the reminder sat + * on top of 「我的应用」 right after the first app was built. + * - `sessionStorage` is per tab. A "new session" read from it fires in any + * second tab the user opens during that same first sitting. + * - The client holds no session id to compare: `AuthClientSession` carries the + * bearer token, and an SSO re-entry can mint a new one within one sitting. + * + * Elapsed time is the one signal that cannot fire inside the first sitting. + * The ruling allows the reminder from the second session or the second day; a + * second session on the same day may still find it quiet, which errs on the + * side the ruling protects. + * + * A stored value that is not a timestamp — the legacy `'1'` flag, or anything + * unreadable — is re-recorded as now, so the grace period starts over rather + * than reading as "long ago". No storage, or storage that throws (a private + * window), answers `false`: when it cannot tell, it does not nag. + */ +export function isPastFirstSession(storage: ReminderStorage | undefined, now: number): boolean { + if (!storage) return false; + let raw: string | null; + try { + raw = storage.getItem(RECOVERY_REMINDER_FIRST_SEEN_KEY); + } catch { + return false; + } + const firstSeen = raw === null ? NaN : Number(raw); + if (!Number.isFinite(firstSeen) || firstSeen < EPOCH_MS_FLOOR) { + try { + storage.setItem(RECOVERY_REMINDER_FIRST_SEEN_KEY, String(now)); + } catch { + /* ignore */ + } + return false; + } + return now - firstSeen >= FIRST_SESSION_GRACE_MS; +} + +export function deviceStorage(): Storage | undefined { + try { + return typeof localStorage !== 'undefined' ? localStorage : undefined; + } catch { + return undefined; + } +} + +export function isDismissed(storage: ReminderStorage | undefined): boolean { + try { + return storage?.getItem(RECOVERY_REMINDER_DISMISSED_KEY) === '1'; + } catch { + return false; + } +} diff --git a/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx b/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx index 255073ec4a..848eb88003 100644 --- a/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx +++ b/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx @@ -1,8 +1,12 @@ /** * CreateWorkspaceDialog * - * Dialog for creating a new workspace (organization). - * Auto-generates a slug from the name. + * Dialog for creating a new workspace (organization). It asks for the NAME + * only (objectui#11659): the URL slug is generated from the name and never + * shown here. A customer creating a workspace never sees the slug take effect + * at this step, so a field for it was a question with no visible answer. The + * owner can still change it later, in organization settings (`SettingsPage`'s + * slug input). * * @module */ @@ -34,7 +38,8 @@ import { resolveOrgErrorMessage } from './orgErrorMessage.js'; * yields the empty string — and an empty slug left the "Create workspace" * button permanently disabled (`!slug.trim()`), dead-ending the FIRST step of * onboarding for every non-Latin-name user. Rather than block them, fall back - * to a deterministic, non-empty slug they can still edit. + * to a deterministic, non-empty slug, which the owner can still change in + * organization settings. * * Deterministic (not random) on purpose: a name-derived hash means the slug * doesn't jitter on every keystroke while typing a CJK name, and re-typing the @@ -59,6 +64,54 @@ function nameToSlug(name: string): string { return `workspace-${hash.toString(36).slice(0, 6)}`; } +/** + * The two answers better-auth gives when the requested slug is already in use. + * `createOrganization` refuses a taken slug with `ORGANIZATION_ALREADY_EXISTS` + * (its create route looks the slug up before inserting); a runtime that maps + * the collision itself answers `ORGANIZATION_SLUG_ALREADY_TAKEN`. + */ +const SLUG_TAKEN_CODES = new Set(['ORGANIZATION_ALREADY_EXISTS', 'ORGANIZATION_SLUG_ALREADY_TAKEN']); + +/** Attempts per submit: the generated slug, then two suffixed variants. */ +const SLUG_ATTEMPTS = 3; + +function isSlugTaken(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const code = (err as { code?: unknown }).code; + return typeof code === 'string' && SLUG_TAKEN_CODES.has(code); +} + +/** + * A variant of `base` for a retry after a slug collision: a short random + * suffix, kept inside the 48-character budget `nameToSlug` writes to. + */ +function suffixedSlug(base: string): string { + const suffix = Math.random().toString(36).slice(2, 6).padEnd(4, '0'); + return `${base.slice(0, 43).replace(/-+$/, '')}-${suffix}`; +} + +/** + * 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. + */ +async function createWithGeneratedSlug( + create: (data: { name: string; slug: string }) => Promise, + name: string, + slug: string, +): Promise { + let attemptSlug = slug; + for (let attempt = 1; ; attempt++) { + try { + return await create({ name, slug: attemptSlug }); + } catch (err) { + if (attempt >= SLUG_ATTEMPTS || !isSlugTaken(err)) throw err; + attemptSlug = suffixedSlug(slug); + } + } +} + interface CreateWorkspaceDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -74,8 +127,10 @@ export function CreateWorkspaceDialog({ const { createOrganization, getAuthConfig } = useAuth(); const [name, setName] = useState(''); - const [slug, setSlug] = useState(''); - const [slugManuallyEdited, setSlugManuallyEdited] = useState(false); + // The slug is not asked for (objectui#11659): it is derived from the name on + // every render and sent with the create call. Empty exactly when the trimmed + // name is empty, so the submit gate below stays a name check. + const slug = nameToSlug(name); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); // Defense-in-depth: the toolbar button that opens this dialog is already @@ -100,19 +155,10 @@ export function CreateWorkspaceDialog({ }; }, [open, getAuthConfig]); - // Auto-generate slug from name (unless manually edited) - useEffect(() => { - if (!slugManuallyEdited) { - setSlug(nameToSlug(name)); - } - }, [name, slugManuallyEdited]); - // Reset form when dialog opens useEffect(() => { if (open) { setName(''); - setSlug(''); - setSlugManuallyEdited(false); setError(null); } }, [open]); @@ -134,7 +180,7 @@ export function CreateWorkspaceDialog({ setError(null); try { - const org = await createOrganization({ name: name.trim(), slug: slug.trim() }); + const org = await createWithGeneratedSlug(createOrganization, name.trim(), slug.trim()); // 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 @@ -202,25 +248,6 @@ export function CreateWorkspaceDialog({ /> -
- - { - setSlug(e.target.value); - setSlugManuallyEdited(true); - }} - data-testid="workspace-slug-input" - /> -

- {t('workspace.slugHint', { defaultValue: 'Used in URLs. Only lowercase letters, numbers, and hyphens.' })} -

-
- {error && (

{error} 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 eb87ca3624..15413c796b 100644 --- a/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx +++ b/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx @@ -58,9 +58,9 @@ async function settleAuthConfig() { }); } -function fillAndSubmit(name = NEW_ORG.name, slug = NEW_ORG.slug) { +/** The dialog asks for the name only (objectui#11659): there is no slug field to fill. */ +function fillAndSubmit(name = NEW_ORG.name) { fireEvent.change(screen.getByTestId('workspace-name-input'), { target: { value: name } }); - fireEvent.change(screen.getByTestId('workspace-slug-input'), { target: { value: slug } }); const form = screen.getByTestId('create-workspace-dialog').querySelector('form'); fireEvent.submit(form as HTMLFormElement); } @@ -119,4 +119,75 @@ describe('CreateWorkspaceDialog', () => { expect(screen.queryByTestId('workspace-create-error')).toBeNull(); warn.mockRestore(); }); + + // objectui#11659 ruling 2: the dialog asks for the name only. The slug is + // generated from the name, still sent with the create call, and stays + // editable in organization settings. + describe('asks for the name only (objectui#11659)', () => { + it('renders no slug field — the name is the only input', async () => { + render( {}} />); + await settleAuthConfig(); + + const dialog = screen.getByTestId('create-workspace-dialog'); + expect(screen.queryByTestId('workspace-slug-input')).toBeNull(); + expect(dialog.querySelectorAll('input')).toHaveLength(1); + expect(screen.getByTestId('workspace-name-input')).toBeInTheDocument(); + }); + + it('sends the slug generated from the name, and the submit gate is the name alone', async () => { + render( {}} />); + await settleAuthConfig(); + + const submit = screen.getByTestId('workspace-create-submit'); + expect(submit).toBeDisabled(); + fireEvent.change(screen.getByTestId('workspace-name-input'), { target: { value: '博远贸易' } }); + expect(submit).not.toBeDisabled(); + fireEvent.submit(screen.getByTestId('create-workspace-dialog').querySelector('form') as HTMLFormElement); + + await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1)); + const sent = createOrganization.mock.calls[0][0] as { name: string; slug: string }; + expect(sent.name).toBe('博远贸易'); + // A CJK name has no ASCII-sluggable characters: the deterministic fallback. + expect(sent.slug).toMatch(/^workspace-[0-9a-z]+$/); + }); + + it('retries a slug collision with a suffixed slug instead of showing an error the user cannot fix', async () => { + // A whole implementation, not a `…Once` queue: a queued value this case + // leaves unconsumed (when the retry is missing) would survive + // `vi.clearAllMocks()` and answer the next case's first call. + createOrganization.mockImplementation(async ({ slug }: { slug: string }) => { + if (slug === 'acme-inc') { + throw Object.assign(new Error('Organization already exists'), { code: 'ORGANIZATION_ALREADY_EXISTS' }); + } + return NEW_ORG; + }); + const onCreated = vi.fn(); + render( {}} onCreated={onCreated} />); + await settleAuthConfig(); + + fillAndSubmit(); + + await waitFor(() => + 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(screen.queryByTestId('workspace-create-error')).toBeNull(); + }); + + it('does not retry a refusal that is not a slug collision', async () => { + createOrganization.mockRejectedValue( + Object.assign(new Error('nope'), { code: 'YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION' }), + ); + render( {}} />); + await settleAuthConfig(); + + fillAndSubmit(); + + expect(await screen.findByTestId('workspace-create-error')).toBeInTheDocument(); + expect(createOrganization).toHaveBeenCalledTimes(1); + expect(provisionMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 3ab83b8986..31dfba7010 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -3072,7 +3072,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.auto.nodeEnd': 'End', 'engine.studio.auto.savedDraft': 'Automation “{label}” saved as draft', 'engine.studio.auto.defaultOff': 'Off by default · review before enabling', - 'engine.studio.auto.heading': 'Automations · flow', + 'engine.studio.auto.heading': 'Automations', 'engine.studio.auto.on': 'On', 'engine.studio.auto.off': 'Off', 'engine.studio.auto.offTitle': 'Disabled — will not run', @@ -5916,8 +5916,8 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.auto.nodeStart': '开始', 'engine.studio.auto.nodeEnd': '结束', 'engine.studio.auto.savedDraft': '自动化「{label}」已存为草稿', - 'engine.studio.auto.defaultOff': '默认 OFF · 审阅后再启用', - 'engine.studio.auto.heading': '自动化 · flow', + 'engine.studio.auto.defaultOff': '默认停用 · 审阅后再启用', + 'engine.studio.auto.heading': '自动化', 'engine.studio.auto.on': '已启用', 'engine.studio.auto.off': '已停用', 'engine.studio.auto.offTitle': '已停用 — 不会运行', @@ -5933,7 +5933,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.auto.none': '还没有自动化 — 点「新建」开始', 'engine.studio.auto.namePlaceholder': '名称(如:录用通知)', 'engine.studio.auto.idPlaceholder': '标识符(如:offer_notice)', - 'engine.studio.auto.canvasHint': '可视化编排 · 点选节点配置', + 'engine.studio.auto.canvasHint': '点选画布上的节点即可配置', 'engine.studio.auto.pick': '选择一个自动化', 'engine.studio.auto.config': '配置', 'engine.studio.auto.emptyLine1': '在画布里点选一个节点,', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.test.tsx index 0f6f965816..0ab2e12d51 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import { DashboardDefaultInspector } from './DashboardDefaultInspector'; +import { t } from '../i18n'; afterEach(cleanup); @@ -108,3 +109,58 @@ describe('DashboardDefaultInspector — basics', () => { expect(labelledInput('Description')).toBeDisabled(); }); }); + +// objectui#11659 ruling 5: the widgets list names each widget's kind in the +// designer's language — the name the add-widget picker shows for it — not the +// stored `type` id. The acceptance run read 「按客户状态统计数量 · bar」. +describe('DashboardDefaultInspector — the widgets list names each kind (objectui#11659)', () => { + const draftWithChart = { + name: 'crm', + label: 'CRM', + widgets: [ + { id: 'w_bar', type: 'bar', title: '按客户状态统计数量' }, + { id: 'w_kpi', type: 'metric', title: 'Revenue' }, + { id: 'w_odd', type: 'not-a-catalogued-type', title: 'Legacy' }, + ], + }; + const kindOf = (id: string) => + document.querySelector(`[data-widget-kind="${id}"]`) as HTMLElement | null; + + for (const locale of ['zh-CN', 'en-US'] as const) { + it(`${locale}: a bar widget reads the picker's name for a bar chart, and keeps its id on hover`, () => { + render( + , + ); + const bar = kindOf('bar'); + expect(bar).not.toBeNull(); + const name = t('engine.widgetPicker.type.bar', locale); + // A real row, not the key echoed back. + expect(name).not.toBe('engine.widgetPicker.type.bar'); + expect(bar!.textContent).toBe(name); + expect(bar!.textContent).not.toBe('bar'); + expect(bar).toHaveAttribute('title', 'bar'); + expect(kindOf('metric')!.textContent).toBe(t('engine.widgetPicker.type.metric', locale)); + // No row prints a bare stored id where the catalogue has a name for it. + expect(screen.queryByText('bar', { selector: 'code' })).toBeNull(); + }); + } + + it('a type the catalogue does not know still prints its id', () => { + render( + , + ); + expect(kindOf('not-a-catalogued-type')!.textContent).toBe('not-a-catalogued-type'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx index 2ba1707080..a987facbbf 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx @@ -221,7 +221,18 @@ export function DashboardDefaultInspector({ w?.id || tFormat('engine.inspector.widget.untitledN', locale, { n: i + 1 })} - {w?.type} + {/* The widget's KIND in the designer's language — the name + the add-widget picker shows for it (objectui#11659), not + the stored `type` id (`bar`). The id stays on hover for + authors. A type the catalogue does not know has no name + to show, so it prints its id as before. */} + + {meta ? tr(meta.labelKey) : w?.type} + {!readOnly && (