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
12 changes: 12 additions & 0 deletions .changeset/ai-plan-approve-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/plugin-chatbot': patch
'@object-ui/app-shell': patch
---

Plan-card approval gives immediate in-card feedback (#2627): clicking
"Build it" flips the clicked card to a spinning "Building…" badge right away
(the approval's chat-level effects land at the bottom of the thread, outside
the viewport, so the card looked untouched for ~10s and users double-clicked).
The durable Built state still derives from the message stream; an approval
that never left the client (rate limit / offline) rolls the badge back so the
button returns. New `planBuildingLabel` prop (AiChatPage passes zh).
21 changes: 21 additions & 0 deletions .changeset/login-config-race-sso-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/auth': patch
---

Login-page auth-config hardening (#2625, #2626):

- `createAuthClient.getConfig` now single-flights + caches the `/auth/config`
fetch (the login page's three consumers used to fire three requests) and
retries failures with backoff (500ms/1.5s/3.5s, 8s per-attempt abort) before
rejecting. A cold-starting environment kernel no longer strands the page
without its SSO buttons; a final failure clears the cache so later callers
retry.
- `LoginForm` holds a spinner instead of painting the password-form defaults
while config resolves — an SSO-only deployment must never flash a password
wall at JIT users who have no password. A failed config still falls back to
the password form (break-glass beats lock-out).
- `signInWithProvider` gains a 20s watchdog: a sign-in request that hangs now
rejects with a clear timeout error so the provider button recovers instead
of spinning forever.
- Removed LoginForm's duplicate "or" divider — SocialSignInButtons already
renders its own, and the stacked pair read as a rendering glitch.
8 changes: 8 additions & 0 deletions .changeset/register-double-divider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@object-ui/auth': patch
---

RegisterForm: drop the duplicate "or" divider (matching the LoginForm fix in
#2629). SocialSignInButtons already renders its own "or continue with email"
divider under the provider buttons; RegisterForm stacked a second "OR" line on
top, which read as a rendering glitch on the sign-up page.
14 changes: 14 additions & 0 deletions .changeset/welcome-cta-autocreate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/components': patch
'@object-ui/app-shell': patch
---

Welcome-page "Create your environment" deep-links straight into the create
dialog (#844): `action:button` gains a client-side `autoTrigger` flag (runs
the action once on mount — same execute path as a click, so param dialogs /
confirms / entitlement gates still apply), and the environments list consumes
`?runAction=create_environment` to mark its create action once entitlements
resolve (upgrade-locked orgs get the upgrade prompt instead; the param is
stripped after consumption so refresh/back don't re-open). Also localizes the
EnvironmentListToolbar's state-aware label overrides ({en,zh}) — they were
hard-coded English inside a zh console.
3 changes: 3 additions & 0 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2158,6 +2158,9 @@ export function ChatPane({
planApproveLabel={t('console.ai.planApprove', { defaultValue: 'Build it' })}
planAdjustLabel={t('console.ai.planAdjust', { defaultValue: 'Adjust' })}
planBuiltLabel={t('console.ai.planBuilt', { defaultValue: 'Built' })}
planBuildingLabel={
convZh ? '正在搭建…' : t('console.ai.planBuilding', { defaultValue: 'Building…' })
}
planReadyLabel={t('console.ai.planReady', {
defaultValue: 'The plan is ready. Build it now, or tell me what to adjust.',
})}
Expand Down
7 changes: 6 additions & 1 deletion packages/app-shell/src/console/home/CloudOnboardingNext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,12 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
<div className="flex flex-col items-center gap-3" data-onboarding={state.phase}>
<div className="flex flex-wrap items-center justify-center gap-3">
{showCreatePrimary ? (
<Button size="lg" onClick={() => navigate(envsRoute)}>
// Deep-link straight INTO the create dialog (#844): the environments
// list consumes `runAction=create_environment` and auto-opens its
// create action once entitlements resolve — "Create your environment"
// used to be a plain navigation that left the user hunting for a
// second create button on the list page.
<Button size="lg" onClick={() => navigate(`${envsRoute}?runAction=create_environment`)}>
<Plus className="mr-2 h-4 w-4" />
{pick({ en: 'Create your environment', zh: '创建你的环境' })}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ describe('CloudOnboardingNext', () => {
expect(screen.queryByText('Open Production')).toBeNull();

fireEvent.click(create);
expect(navigateMock).toHaveBeenCalledWith('/apps/cloud_control/sys_environment');
// Deep-links into the create dialog (#844): the environments list consumes
// `runAction=create_environment` and auto-opens its create action.
expect(navigateMock).toHaveBeenCalledWith(
'/apps/cloud_control/sys_environment?runAction=create_environment',
);
});

it('shows "Open Production" once the org has a production env', async () => {
Expand Down
79 changes: 74 additions & 5 deletions packages/app-shell/src/environment/EnvironmentListToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
* so only the label/variant changes — no duplicate POST logic here.
*/

import { useEffect, useRef, useState } from 'react';
import { SchemaRenderer } from '@object-ui/react';
import { Button } from '@object-ui/components';
import { Plus } from 'lucide-react';
Expand All @@ -33,6 +34,51 @@ import {

const CREATE_ACTION = 'create_environment';

/** Resolve an inline {en,zh} label against the document locale. */
function pick(label: { en: string; zh: string }): string {
const lang =
(typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en';
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
}

/**
* Deep-link support (#844): `?runAction=create_environment` on the
* environments route auto-opens the create dialog once entitlements have
* resolved — the welcome page's "Create your environment" CTA uses it so the
* user doesn't land on the list and have to find the create button again.
* The param is consumed exactly once (stripped from the URL on consumption)
* so refresh / back don't re-open the dialog.
*/
function useAutoRunCreate(ctaKind: string | null): boolean {
// Deliberately router-free (window.location + history.replaceState): the
// toolbar also renders in tests/hosts without a Router, and the param is a
// one-shot signal, not navigation state.
const [requested] = useState<boolean>(() => {
try {
return new URLSearchParams(window.location.search).get('runAction') === CREATE_ACTION;
} catch {
return false;
}
});
const consumed = useRef(false);
// Only meaningful once the entitlement state has resolved: `upgrade_...`
// routes to the upgrade dialog instead, and while loading we must not
// trigger an action whose meaning (prod vs dev create) isn't known yet.
const shouldRun = requested && !consumed.current && ctaKind != null;
useEffect(() => {
if (!shouldRun) return;
consumed.current = true;
try {
const url = new URL(window.location.href);
url.searchParams.delete('runAction');
window.history.replaceState(window.history.state, '', url);
} catch {
/* URL cleanup is cosmetic — never fail the trigger over it */
}
}, [shouldRun]);
return shouldRun;
}

interface Props {
/** Toolbar actions already localized by the caller (ObjectView). */
actions: any[];
Expand All @@ -44,9 +90,20 @@ interface Props {

export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Props) {
const toolbarActions = (actions || []).filter((a: any) => a?.locations?.includes('list_toolbar'));
if (toolbarActions.length === 0) return null;

const ctaKind = entitlements?.ready ? decideEnvironmentCta(entitlements) : null;
const autoRunCreate = useAutoRunCreate(toolbarActions.length > 0 ? ctaKind : null);

// Deep-linked "create" while in the upgrade state opens the SAME upgrade
// prompt — the honest answer to "create" here. In an effect, not render:
// onUpgrade sets parent state.
useEffect(() => {
if (autoRunCreate && ctaKind === 'upgrade_for_development' && entitlements) {
onUpgrade(upgradeDialogSpec(entitlements));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoRunCreate, ctaKind]);

if (toolbarActions.length === 0) return null;

// Upgrade state: a free-plan org clicking "create" must NOT POST-then-403.
// Render a primary button that opens the upgrade prompt, plus any other
Expand All @@ -67,21 +124,33 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro
data-testid="environment-add-upgrade"
>
<Plus className="h-4 w-4" />
<span>Add environment</span>
<span>{pick({ en: 'Add environment', zh: '新建环境' })}</span>
</Button>
</>
);
}

// setup_production / add_development / loading: render the bar, overriding only
// the create action's label (and promoting production setup to a primary CTA).
// Labels are locale-aware — the metadata label is already localized by the
// caller, but these state-aware overrides used to be hard-coded English,
// which flashed an English button in a zh console (#844).
const renderedActions = toolbarActions.map((a: any) => {
if (a?.name !== CREATE_ACTION || ctaKind == null) return a;
if (ctaKind === 'setup_production') {
return { ...a, label: 'Set up your production environment', variant: 'primary' };
return {
...a,
label: pick({ en: 'Set up your production environment', zh: '创建你的生产环境' }),
variant: 'primary',
...(autoRunCreate ? { autoTrigger: true } : {}),
};
}
// add_development
return { ...a, label: 'Add development environment' };
return {
...a,
label: pick({ en: 'Add development environment', zh: '新建开发环境' }),
...(autoRunCreate ? { autoTrigger: true } : {}),
};
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ vi.mock('@object-ui/react', async (importActual) => ({
SchemaRenderer: ({ schema }: any) => (
<div data-testid="action-bar">
{(schema.actions || []).map((a: any) => (
<button key={a.name} data-variant={a.variant}>{a.label}</button>
<button key={a.name} data-variant={a.variant} data-autotrigger={a.autoTrigger ? 'true' : undefined}>{a.label}</button>
))}
</div>
),
Expand Down Expand Up @@ -62,3 +62,31 @@ describe('EnvironmentListToolbar', () => {
expect(screen.queryByTestId('environment-add-upgrade')).toBeNull();
});
});

describe('EnvironmentListToolbar — ?runAction=create_environment deep link (#844)', () => {
const withRunActionParam = () => {
const url = new URL(window.location.href);
url.searchParams.set('runAction', 'create_environment');
window.history.replaceState(null, '', url);
};

it('marks the create action autoTrigger once entitlements resolve, then strips the param', async () => {
withRunActionParam();
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ hasProductionEnv: false })} onUpgrade={vi.fn()} />);
const btn = screen.getByText('Set up your production environment');
// The SchemaRenderer stub surfaces autoTrigger as data-autotrigger.
expect(btn.getAttribute('data-autotrigger')).toBe('true');
// Param is consumed exactly once — stripped from the URL.
await vi.waitFor(() => {
expect(new URL(window.location.href).searchParams.get('runAction')).toBeNull();
});
});

it('upgrade state: deep link opens the upgrade prompt instead of a create POST', async () => {
withRunActionParam();
const onUpgrade = vi.fn();
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: false, plan: 'free' })} onUpgrade={onUpgrade} />);
await vi.waitFor(() => expect(onUpgrade).toHaveBeenCalledTimes(1));
expect(onUpgrade.mock.calls[0][0]).toMatchObject({ code: 'DEV_ENV_PLAN_LOCKED' });
});
});
30 changes: 26 additions & 4 deletions packages/auth/src/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,13 @@ export function LoginForm({
// state because SSO routing is a raw fetch, not the shared `signIn` (whose
// `isLoading` drives the password submit button).
const [ssoSubmitting, setSsoSubmitting] = useState(false);
// Config not resolved yet: render a spinner INSTEAD of the password-form
// defaults. Painting the defaults first showed an SSO-only deployment a
// password wall (no SSO button, no enforced collapse) whenever the config
// fetch was slow/failed on first load — and platform-SSO JIT users have no
// password at all (#2625). A FAILED fetch (after the client's retries)
// still falls back to the password form: break-glass beats lock-out.
const [configPending, setConfigPending] = useState(true);

useEffect(() => {
let cancelled = false;
Expand All @@ -206,6 +213,9 @@ export function LoginForm({
.catch(() => {
// SSO is an enhancement, not required — leave the buttons/form hidden
// or shown at their safe defaults.
})
.finally(() => {
if (!cancelled) setConfigPending(false);
});
return () => {
cancelled = true;
Expand Down Expand Up @@ -349,6 +359,14 @@ export function LoginForm({
/>

<div className="space-y-5">
{configPending ? (
/* Auth config still resolving — hold the layout instead of painting
the password-form defaults that may be wrong for this server. */
<div className="flex justify-center py-10" role="status" aria-live="polite" data-testid="login-config-loading">
<AuthSpinner />
</div>
) : (
<>
<SocialSignInButtons mode="sign-in" onProvidersResolved={(hasProviders) => setHasSocialProviders(hasProviders)} />

{passwordFormVisible ? (
Expand All @@ -357,8 +375,9 @@ export function LoginForm({
Server-side the number is guarded by a per-number cooldown +
hourly cap — the resend button mirrors it with a countdown. */
<form onSubmit={handleSubmit} className="space-y-4">
{hasSocialProviders && <AuthDivider label={l.orText} />}

{/* No divider here: SocialSignInButtons already renders its own
"or continue with email" divider under the provider buttons —
stacking a second "or" line read as a rendering glitch (#2625). */}
{error && <AuthErrorBanner message={error} />}

<div className="space-y-2">
Expand Down Expand Up @@ -428,8 +447,9 @@ export function LoginForm({
</form>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{hasSocialProviders && <AuthDivider label={l.orText} />}

{/* No divider here: SocialSignInButtons already renders its own
"or continue with email" divider under the provider buttons —
stacking a second "or" line read as a rendering glitch (#2625). */}
{error && <AuthErrorBanner message={error} />}

<div className="space-y-2">
Expand Down Expand Up @@ -539,6 +559,8 @@ export function LoginForm({
</button>
</div>
)}
</>
)}
</div>

{registerUrl && !ssoEnforced && (
Expand Down
10 changes: 5 additions & 5 deletions packages/auth/src/RegisterForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
AUTH_INPUT_CLASS,
AUTH_LINK_CLASS,
AUTH_PRIMARY_BUTTON_CLASS,
AuthDivider,
AuthErrorBanner,
AuthFormHeader,
AuthSpinner,
Expand Down Expand Up @@ -127,7 +126,6 @@ export function RegisterForm({
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [hasSocialProviders, setHasSocialProviders] = useState(false);

const l = {
nameLabel: labels.nameLabel ?? 'Name',
Expand Down Expand Up @@ -185,11 +183,13 @@ export function RegisterForm({
/>

<div className="space-y-5">
<SocialSignInButtons mode="sign-up" onProvidersResolved={(has) => setHasSocialProviders(has)} />
<SocialSignInButtons mode="sign-up" />

<form onSubmit={handleSubmit} className="space-y-4">
{hasSocialProviders && <AuthDivider label={l.orText} />}

{/* No divider here: SocialSignInButtons already renders its own
"or continue with email" divider under the provider buttons —
stacking a second "or" line read as a rendering glitch (#2625,
matching the LoginForm fix). */}
{error && <AuthErrorBanner message={error} />}

<div className="space-y-2">
Expand Down
26 changes: 26 additions & 0 deletions packages/auth/src/__tests__/LoginForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,29 @@ describe('LoginForm — SSO button pending state (objectui#2458 item 1)', () =>
}
});
});

describe('LoginForm — config-loading gate (#2625)', () => {
it('holds a loading state instead of painting the password form while config resolves', async () => {
let resolveConfig!: (c: AuthPublicConfig) => void;
const pending = new Promise<AuthPublicConfig>((resolve) => { resolveConfig = resolve; });
renderLogin(createMockClient({}, { getConfig: vi.fn().mockReturnValue(pending) }));

// While pending: spinner, NO password form (an SSO-only server must never
// flash a password wall at users who have no password).
expect(screen.getByTestId('login-config-loading')).toBeTruthy();
expect(screen.queryByLabelText('Email')).toBeNull();

resolveConfig({ features: { ssoEnforced: true } });
await waitFor(() => expect(screen.queryByTestId('login-config-loading')).toBeNull());
// Enforced mode honoured on FIRST paint after resolve: password form
// hidden, break-glass link offered.
expect(screen.queryByLabelText('Email')).toBeNull();
expect(screen.getByRole('button', { name: 'Use a password instead' })).toBeTruthy();
});

it('falls back to the password form when config ultimately fails (break-glass beats lock-out)', async () => {
renderLogin(createMockClient({}, { getConfig: vi.fn().mockRejectedValue(new Error('config down')) }));
await screen.findByLabelText('Email');
expect(screen.queryByTestId('login-config-loading')).toBeNull();
});
});
Loading
Loading