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
19 changes: 19 additions & 0 deletions .changeset/11659-console-record-ux.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 10 additions & 5 deletions packages/app-shell/src/console/home/CloudOnboardingNext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -194,7 +199,7 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
) : (
<Button size="lg" onClick={() => openProduction(openUrl)}>
<Rocket className="mr-2 h-4 w-4" />
{t('cloudOnboarding.openProduction')}
{t('cloudOnboarding.openWorkspace')}
</Button>
)}
<Button size="lg" variant="secondary" onClick={() => navigate(envsRoute)}>
Expand Down
73 changes: 3 additions & 70 deletions packages/app-shell/src/console/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* @module
*/

import { useMemo, useState, useEffect } from 'react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMetadata } from '../../providers/MetadataProvider.js';
import { useRecentItems } from '../../hooks/useRecentItems.js';
Expand All @@ -28,6 +28,7 @@ import { useAgents, isAskAgent, agentHasCapability } from '@object-ui/plugin-cha
// unknown→fail-open) lives with the hook.
import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js';
import { HomeAppsStrip } from './HomeAppsStrip.js';
import { RecoveryPasswordReminder } from './RecoveryPasswordReminder.js';
import { HomeActionCenter, HomeContinue, HomeActivity } from './HomeRail.js';
import { useHomeInbox } from '../../hooks/useHomeInbox.js';
import { useNavigationContext } from '../../context/NavigationContext.js';
Expand All @@ -38,7 +39,7 @@ import {
resolveNotificationTarget,
} from '../../utils/index.js';
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
import { Sparkles, ShieldAlert, X, UploadCloud, MessageSquareText, Hammer, LayoutTemplate } from 'lucide-react';
import { Sparkles, UploadCloud, MessageSquareText, Hammer, LayoutTemplate } from 'lucide-react';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts.js';
import { resolveAiApiBase } from '../../hooks/useAiSurface.js';
Expand Down Expand Up @@ -231,74 +232,6 @@ function PendingDraftsBanner({ t }: { t: (key: string, opts?: any) => string })
);
}

/**
* Dismissible nudge 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.
*/
function RecoveryPasswordReminder({ t }: { t: (key: string, opts?: any) => string }) {
const navigate = useNavigate();
const { hasLocalPassword, getAuthConfig } = useAuth();
const [show, setShow] = useState(false);
useEffect(() => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('os:recovery-pw-dismissed') === '1') return;
// Don't nag on the very first landing: let a brand-new SSO user reach
// their magic moment (build their first app) before asking them to set a
// recovery password. Record the first home visit; show the reminder only
// from the next one on. (The component already intends 'not before the
// first session'; previously the code still fired on the very first screen.)
if (typeof localStorage !== 'undefined' && localStorage.getItem('os:recovery-pw-first-seen') !== '1') {
try { localStorage.setItem('os:recovery-pw-first-seen', '1'); } catch { /* ignore */ }
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('os:recovery-pw-dismissed', '1'); } catch { /* ignore */ }
setShow(false);
};
if (!show) return null;
return (
<div className="px-4 sm:px-6 lg:px-8 pt-4">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 rounded-xl border border-amber-300/60 dark:border-amber-700/50 bg-amber-50 dark:bg-amber-950/30 px-4 py-3">
<ShieldAlert className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
<p className="flex-1 min-w-0 text-sm text-amber-900 dark:text-amber-200">
{t('home.recoveryReminder.message', { defaultValue: 'Set a recovery password so you can still sign in if single sign-on is ever unavailable.' })}
</p>
<Button size="sm" variant="outline" onClick={() => navigate('/set-password')} data-testid="recovery-pw-set">
{t('home.recoveryReminder.cta', { defaultValue: 'Set password' })}
</Button>
<button
type="button"
onClick={dismiss}
aria-label={t('home.recoveryReminder.dismiss', { defaultValue: 'Dismiss' })}
className="shrink-0 rounded-md p-1 text-amber-700/70 hover:text-amber-900 dark:text-amber-300/70 dark:hover:text-amber-100"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}

export function HomePage() {
const navigate = useNavigate();
const { t } = useObjectTranslation();
Expand Down
87 changes: 87 additions & 0 deletions packages/app-shell/src/console/home/RecoveryPasswordReminder.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="px-4 sm:px-6 lg:px-8 pt-4">
<div className="max-w-7xl mx-auto">
<div
className="flex items-center gap-3 rounded-xl border border-amber-300/60 dark:border-amber-700/50 bg-amber-50 dark:bg-amber-950/30 px-4 py-3"
data-testid="recovery-pw-reminder"
>
<ShieldAlert className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
<p className="flex-1 min-w-0 text-sm text-amber-900 dark:text-amber-200">
{t('home.recoveryReminder.message', { defaultValue: 'Set a recovery password so you can still sign in if single sign-on is ever unavailable.' })}
</p>
<Button size="sm" variant="outline" onClick={() => navigate('/set-password')} data-testid="recovery-pw-set">
{t('home.recoveryReminder.cta', { defaultValue: 'Set password' })}
</Button>
<button
type="button"
onClick={dismiss}
aria-label={t('home.recoveryReminder.dismiss', { defaultValue: 'Dismiss' })}
className="shrink-0 rounded-md p-1 text-amber-700/70 hover:text-amber-900 dark:text-amber-300/70 dark:hover:text-amber-100"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
Loading
Loading