Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit f9f4a62

Browse files
hotlongclaude
andauthored
fix(app-shell,plugin-detail,i18n): console, record-page and Studio UX from the 2026-10-05 cloud acceptance (objectui#11659) (#11663)
Fixes #11659 Six console, record-page and Studio UX items from the 2026-10-05 cloud acceptance run, each executed as ruled on the card. Dispatched by the epic objectstack-ai/cloud#2636 PM loop; session `https://claude.ai/code/session_64cb68a6-01c7-4dc6-acc6-ccb459c90ef2`. ## What changed, per ruling 1. **Cloud home primary button reads 「进入工作区」 / "Open workspace".** `CloudOnboardingNext` (the `cloud:onboarding-next` widget) now resolves `cloudOnboarding.openWorkspace`, which replaces `cloudOnboarding.openProduction` in all ten packs. The hint under the button (`cloudOnboarding.hintReady`) was the secondary 「生产环境」 wording in the same block; it now says workspace in all ten packs. Destination unchanged: the button still navigates to the page's `openProductionUrl` (pinned by the new zh case). 2. **Create-workspace dialog asks for the name only.** The "URL slug" field is gone from `CreateWorkspaceDialog`. The slug is still generated from the name by the same `nameToSlug` rule and sent with the create call; the owner can change it in organization settings (`SettingsPage` keeps its slug input). Because the user can no longer fix a taken slug in the dialog, a slug collision (`ORGANIZATION_ALREADY_EXISTS`, which is what better-auth's create route throws for a taken slug, or `ORGANIZATION_SLUG_ALREADY_TAKEN`) is retried with a short random suffix, three attempts in all. Any other refusal is shown as before. Submit is still gated on a non-empty slug, which is non-empty exactly when the trimmed name is. 3. **Backup-password reminder stays quiet in the first session.** Mechanism: elapsed time. The reminder is quiet for 12 hours after the first home visit on the device, so it first shows on a later day's visit. Why this and not the alternatives: - the old gate counted home visits (quiet on the first mount, shown on the second), and "build the first app, come back to home" is the second mount inside the first sitting, which is exactly what the acceptance run saw; - `sessionStorage` is per tab, so a "new session" read from it fires in any second tab of the 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). A device holding the old `'1'` flag starts the 12 hours over instead of reading it as long ago, and unavailable storage stays quiet. The component moved out of `HomePage.tsx` into `RecoveryPasswordReminder.tsx`, with the gate in `recoveryReminderGate.ts`; neither is exported from the package entry. 4. **Record drawer header does not cut the phone number.** The highlight row is `HeaderHighlight` (plugin-detail), mounted by `record:highlights` inside the record drawer's `RecordDetailView`. The phone cell draws a dial icon, the number and a copy button in one `inline-flex` box, and the chip's `truncate` span cannot put an ellipsis on an inline-flex child: it clips it mid-glyph. A `phone` chip now takes the wide basis that `email` and `url` already take, so a whole number shows; the full value stays in the chip's hover title. 5. **Studio dashboard widgets list shows a localized widget kind.** `DashboardDefaultInspector`'s widgets list printed the stored `type` beside each title. It now prints the add-widget picker's name for that kind (`engine.widgetPicker.type.*`: zh 柱状图, en "Bar chart"), with the id on hover. An uncatalogued type still prints its id. 6. **Studio automation pillar zh copy drops `flow` and `OFF`.** 自动化 · flow becomes 自动化; 默认 OFF · 审阅后再启用 becomes 默认停用 · 审阅后再启用 (matching the pillar's own 已启用 / 已停用); 可视化编排 · 点选节点配置 becomes 点选画布上的节点即可配置. The en heading drops the type too ("Automations"). The pillar is not hidden; whether it is offered is cloud#2627's call. ## Premises checked - **C1, half true.** The button text on the cloud home comes from objectui (`cloudOnboarding.openProduction`, rendered by `CloudOnboardingNext`). But cloud's `packages/service-tenant/src/pages/welcome.page.ts` carries its own copy that QUOTES the old label, so after this lands and the pin moves, cloud's page will say one thing and the button another. Cloud was not edited. What cloud needs, all inside `gettingStarted()` and `cardGrid()`: - Step 1's description (`gs_1`) ends with en `Enter it with "Open Production" above.`, zh 从上方的「打开生产环境」进入。, ja 上の「本番環境を開く」から入ります。, es Entra desde «Abrir producción» arriba. Each should quote the new label: "Open workspace", 「进入工作区」, 「ワークスペースを開く」, «Abrir espacio de trabajo». The same sentence opens with "Your production environment is already set up" / 你的生产环境已自动就绪, which is the same operator wording. - Step 1's title and the hero subtitle say 打开你的环境 / "Open your environment"; optional alignment with 进入工作区. - The "Manage your environments" card description says 你的生产环境,以及需要时的开发副本. That card is the environments surface, where production vs dev is the point; recommended to leave it. - **C2, true.** The slug field was the `workspace-slug-input` in `CreateWorkspaceDialog`. Hiding it keeps validation and submit working (pinned), and adds collision retry because the field was the user's only way to recover from a taken slug. - **C3, true.** zh `home.recoveryReminder.message` is rendered only by `RecoveryPasswordReminder`, which `HomePage` mounts in both its empty and its app-list branches. - **C4, located.** Item 4: `HeaderHighlight` in `packages/plugin-detail`, via `record:highlights`. Item 5: the widgets list in `DashboardDefaultInspector` (the dashboard's default right panel in Studio). The card's middle dot was a paraphrase: the title and the type sit in separate elements. ## Verification Final head for the unions below: tests and type-check for app-shell and i18n at `5ffdd171c`, plugin-detail at `992c3c2cc` (the one later commit touches only a plugin-detail test file). | gate | result | |---|---| | `pnpm exec vitest run packages/app-shell/` | 1010 files passed, 1 skipped; 10040 tests passed (exit 0) | | `pnpm exec vitest run packages/plugin-detail/` | 239 files passed, 1 skipped; 2385 tests passed (exit 0) | | `pnpm exec vitest run packages/i18n/` | 79 files passed; 1289 tests passed (exit 0) | | `pnpm --filter @object-ui/app-shell run type-check` | exit 0; `tsconfig.test.json` lists all five touched app-shell test files | | `pnpm --filter @object-ui/plugin-detail run type-check` | exit 0 | | `pnpm --filter @object-ui/i18n run type-check` | exit 0 | | `node scripts/check-changeset-presence.mjs` | exit 0 | | `pnpm run check:new-line-citations` | 0 new citations over the 25 branch files, exit 0 | | `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:changeset-claims`, `check:pending-changeset-literals`, `check-changeset-no-major` | all exit 0 | | eslint on the 24 touched TS files (`--no-inline-config`) | 0 errors | Lint is a declared narrowing, not the repo-wide `pnpm lint` (CI runs that): the population is the branch's touched `.ts`/`.tsx` files, 24 by eslint's own JSON file count, and `eslint.config.js` enables no type-aware linting (no `parserOptions.project` or `projectService`), so this diff cannot move a verdict on an untouched file. The warnings on the touched files are pre-existing `no-explicit-any` and one pre-existing `set-state-in-effect` on the dialog's reset effect; two `any`s moved with the reminder from `HomePage.tsx` into its new module. This machine is macOS, so the shared verify lock ran in its declared unlocked mode (no `flock`): these runs had no mutual exclusion with sibling agents. ### Ablation, one per item at least Each leg mutated the committed source through `scripts/ablation-replace.mjs` (anchor must hit once), ran the item's test, and restored with `git checkout HEAD`, proven by blob hash equal to HEAD and an empty `git diff HEAD`. | item | mutation | red | |---|---|---| | 1 | zh `openWorkspace` back to 打开生产环境 | 1 failed / 7 (the zh case) | | 2 | slug input put back in the dialog | 1 failed / 7 (no-slug-field case) | | 2 | collision retry removed | 1 failed / 7 (retry case) | | 3 | gate answers `true` (visit-count behaviour) | 2 failed / 8 (both first-sitting cases) | | 4 | `phone` dropped from the wide family | 1 failed / 3 | | 5 | list prints `w.type` again | 2 failed / 8 (zh and en) | | 6 | zh heading back to 自动化 · flow | 1 failed / 4 | The first retry-ablation run also failed the following case: the retry case's `…Once` queue survived `vi.clearAllMocks()` and answered the next case. The case now uses a whole implementation keyed on the slug, and the re-run fails only the retry case. ### Browser NOT VERIFIED IN BROWSER, all six items: the console needs a backend, and the dev harnesses that run without one (`preview-gallery.html`, `record-header-preview.html`) do not mount these surfaces. The item-4 mechanism was measured on a static probe that mirrors the chip's CSS (border-box sizing, 9rem vs 16rem basis, a `truncate` span around an inline-flex phone box): on the 9rem basis the clip box was 103px for 164px of content and the number was cut mid-glyph with no ellipsis; on the 16rem basis the box was 215px and nothing was clipped. That is a CSS measurement, not a screenshot of the product. The epic's final re-walk covers the zh-CN and en screenshots. ## Acceptance notes - `StudioDesignSurface`'s automation canvas header still prints a literal `flow · NAME` chip beside the hint when a flow is open, in every locale. It is outside this card's claimed file surface, so it is untouched here; it is the same "drop `flow`" family as ruling 6. - Link-shaped cell renderers (phone, email, url) draw an inline-flex root, so a highlight value that outgrows even the wide basis is still clipped without an ellipsis; the hover title carries it. Polish, no carrier. - `workspace.slugLabel` and `workspace.slugHint` are now unread in all ten packs; left in place (the dead-key report is report-only). - 「工作区」 now names the environment on the cloud home and in the environment home's loading text, and the organization in the create-workspace dialog. Since the environment is named after its workspace (the naming collapse), a new customer sees one thing; noted, not changed. - Overlap: objectui#11662 (the parallel card's PR) touches the same ten packs, on other keys. A `git merge-tree` of the two heads is clean. --- _Generated by [Claude Code](https://claude.ai/code/session_64cb68a6-01c7-4dc6-acc6-ccb459c90ef2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc3c2cc commit f9f4a62

25 files changed

Lines changed: 808 additions & 149 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
'@object-ui/plugin-detail': patch
5+
---
6+
7+
Console, record page and Studio copy from the 2026-10-05 cloud acceptance run (objectui#11659).
8+
9+
**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.
10+
11+
**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.
12+
13+
**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.
14+
15+
**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.
16+
17+
**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.
18+
19+
**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.

‎packages/app-shell/src/console/home/CloudOnboardingNext.tsx‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@
1313
* the same `/cloud/environment-entitlements` endpoint the environment list uses
1414
* and renders the right primary action:
1515
*
16-
* • has production env → "Open Production" (the doorway into the env, where
16+
* • has production env → "Open workspace" (the doorway into the env, where
1717
* building happens) + a "Manage environments" link.
18+
* The label names the place in the customer's words,
19+
* not the operator's: a new customer has exactly one
20+
* environment, and "production" is control-plane
21+
* vocabulary (objectui#11659). The destination is
22+
* still the org's production environment.
1823
* • no production env → "Create your environment" (the real first step).
1924
* • loading → a neutral skeleton (no CTA flashes / layout jump).
2025
* • unknown / error → degrade to BOTH actions, so the button always works.
2126
*
22-
* Routes and the "Open Production" endpoint come from the page metadata
27+
* Routes and the "Open workspace" endpoint come from the page metadata
2328
* (`properties`) so the Cloud app owns its URLs; this component owns only the
2429
* state logic. It mirrors `useEnvironmentEntitlements`' authoritative summary
2530
* fetch (org-scoped GET) without its list-only gating.
@@ -53,7 +58,7 @@ interface CloudOnboardingNextProps {
5358
* Optional backend pre-warm endpoint. When set and the caller already has
5459
* a production env, the widget fires a best-effort GET the moment it knows
5560
* the env exists — nudging a possibly-asleep container awake WHILE the user
56-
* reads the hint, so the later "Open Production" click lands in an
61+
* reads the hint, so the later "Open workspace" click lands in an
5762
* already-waking env instead of paying the full cold-start. No-op when
5863
* unset (older page metadata), so this is safe to ship ahead of the page.
5964
*/
@@ -118,7 +123,7 @@ function useProductionEnvState(warmUrl?: string): Resolved {
118123
setState({ phase: 'ready', hasProductionEnv });
119124
// Pre-warm the prod env the instant we know it exists — fire-and-forget,
120125
// best-effort, once per resolve. The user is now reading the hint and
121-
// will click "Open Production" seconds later; by then the container is
126+
// will click "Open workspace" seconds later; by then the container is
122127
// already waking. The sso-open click warms again, so a failed warm here
123128
// costs nothing. Only when the page passes a warmUrl (else no-op).
124129
if (hasProductionEnv && warmUrl) {
@@ -194,7 +199,7 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
194199
) : (
195200
<Button size="lg" onClick={() => openProduction(openUrl)}>
196201
<Rocket className="mr-2 h-4 w-4" />
197-
{t('cloudOnboarding.openProduction')}
202+
{t('cloudOnboarding.openWorkspace')}
198203
</Button>
199204
)}
200205
<Button size="lg" variant="secondary" onClick={() => navigate(envsRoute)}>

‎packages/app-shell/src/console/home/HomePage.tsx‎

Lines changed: 3 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* @module
1616
*/
1717

18-
import { useMemo, useState, useEffect } from 'react';
18+
import { useMemo } from 'react';
1919
import { useNavigate } from 'react-router-dom';
2020
import { useMetadata } from '../../providers/MetadataProvider.js';
2121
import { useRecentItems } from '../../hooks/useRecentItems.js';
@@ -28,6 +28,7 @@ import { useAgents, isAskAgent, agentHasCapability } from '@object-ui/plugin-cha
2828
// unknown→fail-open) lives with the hook.
2929
import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js';
3030
import { HomeAppsStrip } from './HomeAppsStrip.js';
31+
import { RecoveryPasswordReminder } from './RecoveryPasswordReminder.js';
3132
import { HomeActionCenter, HomeContinue, HomeActivity } from './HomeRail.js';
3233
import { useHomeInbox } from '../../hooks/useHomeInbox.js';
3334
import { useNavigationContext } from '../../context/NavigationContext.js';
@@ -38,7 +39,7 @@ import {
3839
resolveNotificationTarget,
3940
} from '../../utils/index.js';
4041
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
41-
import { Sparkles, ShieldAlert, X, UploadCloud, MessageSquareText, Hammer, LayoutTemplate } from 'lucide-react';
42+
import { Sparkles, UploadCloud, MessageSquareText, Hammer, LayoutTemplate } from 'lucide-react';
4243
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
4344
import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts.js';
4445
import { resolveAiApiBase } from '../../hooks/useAiSurface.js';
@@ -231,74 +232,6 @@ function PendingDraftsBanner({ t }: { t: (key: string, opts?: any) => string })
231232
);
232233
}
233234

234-
/**
235-
* Dismissible nudge to set a local recovery password — shown when the user
236-
* signed in via SSO and has no local credential yet. We no longer force this
237-
* before the first session (it walled off the magic moment); this gentle,
238-
* one-time reminder preserves instance self-sufficiency without the friction.
239-
*/
240-
function RecoveryPasswordReminder({ t }: { t: (key: string, opts?: any) => string }) {
241-
const navigate = useNavigate();
242-
const { hasLocalPassword, getAuthConfig } = useAuth();
243-
const [show, setShow] = useState(false);
244-
useEffect(() => {
245-
if (typeof localStorage !== 'undefined' && localStorage.getItem('os:recovery-pw-dismissed') === '1') return;
246-
// Don't nag on the very first landing: let a brand-new SSO user reach
247-
// their magic moment (build their first app) before asking them to set a
248-
// recovery password. Record the first home visit; show the reminder only
249-
// from the next one on. (The component already intends 'not before the
250-
// first session'; previously the code still fired on the very first screen.)
251-
if (typeof localStorage !== 'undefined' && localStorage.getItem('os:recovery-pw-first-seen') !== '1') {
252-
try { localStorage.setItem('os:recovery-pw-first-seen', '1'); } catch { /* ignore */ }
253-
return;
254-
}
255-
let cancelled = false;
256-
Promise.all([
257-
Promise.resolve(hasLocalPassword?.()),
258-
Promise.resolve(getAuthConfig?.()).catch(() => null),
259-
])
260-
.then(([has, config]: [unknown, any]) => {
261-
if (cancelled) return;
262-
// Skip on SSO-enforced envs: password login is disabled there, so a
263-
// recovery password can't be used — nudging for one is misleading and
264-
// gives a false sense of security. Same signal LoginForm uses.
265-
const passwordUnavailable =
266-
config?.features?.ssoEnforced === true || config?.emailPassword?.enabled === false;
267-
if (has === false && !passwordUnavailable) setShow(true);
268-
})
269-
.catch(() => { /* unknown → don't nag */ });
270-
return () => { cancelled = true; };
271-
}, [hasLocalPassword, getAuthConfig]);
272-
const dismiss = () => {
273-
try { localStorage.setItem('os:recovery-pw-dismissed', '1'); } catch { /* ignore */ }
274-
setShow(false);
275-
};
276-
if (!show) return null;
277-
return (
278-
<div className="px-4 sm:px-6 lg:px-8 pt-4">
279-
<div className="max-w-7xl mx-auto">
280-
<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">
281-
<ShieldAlert className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
282-
<p className="flex-1 min-w-0 text-sm text-amber-900 dark:text-amber-200">
283-
{t('home.recoveryReminder.message', { defaultValue: 'Set a recovery password so you can still sign in if single sign-on is ever unavailable.' })}
284-
</p>
285-
<Button size="sm" variant="outline" onClick={() => navigate('/set-password')} data-testid="recovery-pw-set">
286-
{t('home.recoveryReminder.cta', { defaultValue: 'Set password' })}
287-
</Button>
288-
<button
289-
type="button"
290-
onClick={dismiss}
291-
aria-label={t('home.recoveryReminder.dismiss', { defaultValue: 'Dismiss' })}
292-
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"
293-
>
294-
<X className="h-4 w-4" />
295-
</button>
296-
</div>
297-
</div>
298-
</div>
299-
);
300-
}
301-
302235
export function HomePage() {
303236
const navigate = useNavigate();
304237
const { t } = useObjectTranslation();
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* RecoveryPasswordReminder
5+
*
6+
* Dismissible nudge on the environment home to set a local recovery password —
7+
* shown when the user signed in via SSO and has no local credential yet. We no
8+
* longer force this before the first session (it walled off the magic moment);
9+
* this gentle, one-time reminder preserves instance self-sufficiency without the
10+
* friction.
11+
*
12+
* @module
13+
*/
14+
15+
import { useEffect, useState } from 'react';
16+
import { useNavigate } from 'react-router-dom';
17+
import { useAuth } from '@object-ui/auth';
18+
import { Button } from '@object-ui/components';
19+
import { ShieldAlert, X } from 'lucide-react';
20+
import {
21+
RECOVERY_REMINDER_DISMISSED_KEY,
22+
deviceStorage,
23+
isDismissed,
24+
isPastFirstSession,
25+
} from './recoveryReminderGate.js';
26+
27+
export function RecoveryPasswordReminder({ t }: { t: (key: string, opts?: any) => string }) {
28+
const navigate = useNavigate();
29+
const { hasLocalPassword, getAuthConfig } = useAuth();
30+
const [show, setShow] = useState(false);
31+
useEffect(() => {
32+
const storage = deviceStorage();
33+
if (isDismissed(storage)) return;
34+
// Not in the user's first session: let a brand-new SSO user reach their
35+
// magic moment (build their first app) before asking them to set a
36+
// recovery password. See `isPastFirstSession` for why "first session" is
37+
// measured in elapsed time.
38+
if (!isPastFirstSession(storage, Date.now())) return;
39+
let cancelled = false;
40+
Promise.all([
41+
Promise.resolve(hasLocalPassword?.()),
42+
Promise.resolve(getAuthConfig?.()).catch(() => null),
43+
])
44+
.then(([has, config]: [unknown, any]) => {
45+
if (cancelled) return;
46+
// Skip on SSO-enforced envs: password login is disabled there, so a
47+
// recovery password can't be used — nudging for one is misleading and
48+
// gives a false sense of security. Same signal LoginForm uses.
49+
const passwordUnavailable =
50+
config?.features?.ssoEnforced === true || config?.emailPassword?.enabled === false;
51+
if (has === false && !passwordUnavailable) setShow(true);
52+
})
53+
.catch(() => { /* unknown → don't nag */ });
54+
return () => { cancelled = true; };
55+
}, [hasLocalPassword, getAuthConfig]);
56+
const dismiss = () => {
57+
try { localStorage.setItem(RECOVERY_REMINDER_DISMISSED_KEY, '1'); } catch { /* ignore */ }
58+
setShow(false);
59+
};
60+
if (!show) return null;
61+
return (
62+
<div className="px-4 sm:px-6 lg:px-8 pt-4">
63+
<div className="max-w-7xl mx-auto">
64+
<div
65+
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"
66+
data-testid="recovery-pw-reminder"
67+
>
68+
<ShieldAlert className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
69+
<p className="flex-1 min-w-0 text-sm text-amber-900 dark:text-amber-200">
70+
{t('home.recoveryReminder.message', { defaultValue: 'Set a recovery password so you can still sign in if single sign-on is ever unavailable.' })}
71+
</p>
72+
<Button size="sm" variant="outline" onClick={() => navigate('/set-password')} data-testid="recovery-pw-set">
73+
{t('home.recoveryReminder.cta', { defaultValue: 'Set password' })}
74+
</Button>
75+
<button
76+
type="button"
77+
onClick={dismiss}
78+
aria-label={t('home.recoveryReminder.dismiss', { defaultValue: 'Dismiss' })}
79+
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"
80+
>
81+
<X className="h-4 w-4" />
82+
</button>
83+
</div>
84+
</div>
85+
</div>
86+
);
87+
}

0 commit comments

Comments
 (0)