fix(onboarding): unify sign-in actions and recover failed logins (#19336)

## Thinking Path

> - Paperclip lets people create agents and connect their AI accounts.
> - Onboarding uses the shared login panel to connect Claude and OpenAI
accounts.
> - The sign-in link did not update the footer, so the footer could open
sign-in again.
> - A failed or expired sign-in also left onboarding without a retry
action.
> - This pull request gives both sign-in controls one action and makes
failed attempts recoverable.
> - Users can return with a code or start a fresh attempt while keeping
their setup.

## Linked Issues or Issue Description

**What happened?**

The onboarding instruction link opens Claude but leaves the footer
offering Sign in to Claude. If a code is entered without a paste event,
the footer opens provider sign-in again. A failed code, expired session,
or failed sandbox request can also leave an error with no retry control.
Component tests reproduce these failures.

This PR addresses that explicit footer action. It does not establish a
fix for unsolicited navigation of the original Paperclip tab to Claude
before code entry. That reported hosted-Cloud behavior remains under
investigation and needs browser-level evidence.

**Expected behavior**

The link and footer should use the same sign-in action. Both should open
one provider tab and put the footer into a disabled Waiting for code…
state. The code field should stay available. Failed or expired attempts
should show a clear message and an enabled Retry sign-in button.

**Steps to reproduce**

1. Choose Claude during onboarding with no saved subscription login.
2. Open Sign in to Claude from the instruction link.
3. Return and enter the authorization code without a paste event, such
as by typing it.
4. Press the footer action. Before this fix, it opens Claude again.

**Paperclip version or commit**

Reproduced in component tests against master at 31bf6e4482.

**Deployment mode**

The fixed footer defect is reproduced in the hosted onboarding component
path. The regression tests use the real wizard and login panel with
simulated API responses. No live Claude authorization was performed.

Related merged work: #13193 added immediate paste feedback; #12921 kept
the pasted code visible. Neither handles this footer path. The duplicate
search found no open PR for this defect.

## What Changed

- Use one handler for the sign-in link and footer. Opening either enters
Waiting for code… and disables the footer. The code field stays
available. The inline link can reopen the same provider URL if the
provider tab was closed.
- Submit an entered Claude code from the footer or Enter. Preserve
automatic submission on paste and block duplicate submissions.
- Replace raw red onboarding errors with a short failure or expiry
message. Hide the stale sign-in link and old code field.
- Add Retry sign-in in the onboarding footer. The login panel owns
recovery: release the old session, recheck the owner’s active session,
then resume it or start a fresh attempt. Clear the old code and keep the
onboarding choices.
- Pause polling during recovery. Treat an already-removed session as
released, but keep other cancellation failures retryable. Keep missing
resumed sessions recoverable.
- Give shared-card hosts without a retry footer an inline Retry sign-in
action.
- Add coverage for both sign-in controls, reopening the current provider
URL, failed codes, startup, lookup, prompt, status, expiry, missing
sessions, completion, cancellation, resume, and hosts without a retry
footer.
- Add failed and expired Claude sign-in stories to the production
onboarding suite. Their scoped fixtures support draft-agent saves,
cancellation, and a fresh second attempt.

The login panel owns code validation, session lifecycle, and retry. The
wizard owns the footer and opens the provider tab. Optional action
callbacks let each host own its controls; existing panel hosts retain
their session behavior.

## Verification

- The original four link and footer regressions failed before the
initial fix.
- `pnpm -r typecheck`: passed across the workspace. UI typecheck was
repeated after the final UI changes.
- From `ui/`, `pnpm exec vitest run --config vitest.config.ts
src/components/OnboardingWizard.test.tsx
src/components/AgentConfigForm.render.test.tsx
src/components/AdapterLoginChrome.test.tsx`: 405 tests passed.
- Component checks confirm that both controls open the same URL once,
disable the waiting footer, and leave the code field usable. Retry waits
for cancellation, clears the old code, and submits the next code to the
new session. Repeated clicks do not create duplicate attempts. Back
still prevents a late failure or success from reopening the card or
hiring.
- `pnpm check:token-gates` and `git diff --check`: passed.
- `pnpm build`: passed across the workspace before the final shared-card
fallback. The UI build was repeated after that change.
- Full local `pnpm test:run` was not repeated for this revision. The
earlier local run on `11fca74` passed all six chat integration shards
(1,931 tests), then was stopped after that head’s complete CI plan
passed. It did not finish locally.
- CI is pending on the updated head. Greptile reviewed
`876142d3877cdfe63e8438d00b605f4b66aded05` and scored it 5/5 with no new
actionable findings. All review threads are resolved.
- The earlier review of `4283b1c` scored 4/5 and found that the link
could not reopen a closed provider tab. That finding was fixed in
`876142d`. The handler accepts the waiting state while the footer stays
disabled. Regression assertions cover reopening the same URL without
starting another session.
- Storybook browser inspection used the real wizard with simulated APIs
in dark mode at 760 × 900. Both new story play functions reached their
completed state. Clicking Retry in the expired story restored an empty
code field and the second attempt’s authorization URL. No provider page
was opened.
- New story IDs: `onboarding-agent-arc--claude-sign-in-failed` and
`onboarding-agent-arc--claude-sign-in-expired`. Run `pnpm storybook` and
select Agent arc → Claude Sign In Failed or Claude Sign In Expired.
- Storybook catalog validation and its 30 node tests passed. This proves
the story structure and the local simulated interaction; it does not
prove a live provider exchange or Linux visual-baseline parity.
- No live provider sign-in or deployed browser acceptance was run. The
original unsolicited main-tab navigation report remains unverified.

Earlier CI evidence remains recorded: on `11fca74`, server shard 2/12
failed in the unchanged `remote-workspace-migration.test.ts`
orphan-index fixture. Creating its Git worktree lock file returned
`ENOENT` before the behavior under test ran. The test and migration
implementation have no diff in this PR. The focused local test passed (1
passed, 124 skipped). The same-head CI rerun and aggregate verification
passed on attempt 2. The cause of that initial failure was not
established.

Current-head CI failure retained: on `876142d`, server shard 11/12
failed in the unchanged `status-card-evaluation-work.test.ts` test
“coalesces one hundred committed source writes without delaying another
company's saved deadline.” The clock assertion at line 384 finished 610
ms after the saved deadline, against a limit of less than 500 ms. There
are no server changes in this PR. The focused local test passed (1
passed, 35 skipped). The aggregate verification job failed because
`general_tests` failed; it reported no separate test defect. The first
shard rerun request was rejected while the workflow was still running.
After the workflow completed, GitHub accepted a rerun of server shard
11/12 and its dependent verification job on the same head. That rerun is
pending. The cause of the initial timing overrun is not established; a
passing local test does not explain it. [Initial failed
job](https://github.com/paperclipai/paperclip/actions/runs/42123655875/job/127583449952).

## Risks

- The onboarding footer now tracks sign-in, code submission, and retry.
The ordinary paste flow stays automatic; typed codes still need an
explicit action.
- Retry cancels the current attempt before starting another one. A
cancellation failure keeps Retry available and prevents a second start.
- The failure card uses short, fixed text instead of raw provider
errors. Standalone configuration panels retain their existing error
presentation.
- No API, credential storage, authorization, schema, or deployment
changes.

## Model Used

OpenAI Codex, `gpt-6.1-sol`, xhigh reasoning. Used repository
inspection, code editing, and local command execution. Context window
size is not exposed by the harness.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <[email protected]>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-05 15:10:50 -08:00
1 parent 002455ea06
commit 5ac05871b5
7 files changed
+591 -69

No files matched your search

+8
View File
@@ -9,6 +9,14 @@ Onboarding and new-agent setup retain upstream's `SavedProviderKeySelect` and
homes. Managed default/shared accounts are additional choices in that same
selector. Selecting “Sign in to another account” survives background refreshes;
Claude authorization paste keeps upstream's immediate Connecting feedback.
The onboarding sign-in link and footer use the same action. Either opens the
provider in a new tab and disables the footer with Waiting for code…. The code
field stays available. A Claude code entered without a paste event can be sent
with Submit code in the footer or Enter in the field. These actions submit to
the current login session and do not open another provider window.
Failed or expired sign-ins show a short message and a Retry sign-in footer
button. Retry releases the old session before starting a fresh sign-in. It keeps
the onboarding setup and clears the old code.
New-agent Connect offers three persistent tiles: the provider's subscription,
the provider's API key, and Advanced. All three stay visible while the selected
+19 -2
View File
@@ -418,12 +418,18 @@ export function OnboardingCardField({
export function ProviderSubscriptionCard({
providerName,
authorizationUrl,
onOpenAuthorization,
mode,
loading,
failure,
onRetry,
children,
}: {
providerName: string;
authorizationUrl?: string;
onOpenAuthorization?: () => void;
failure?: "expired" | "failed";
onRetry?: () => void;
mode: "submitted_code" | "displayed_code" | "browser_redirect";
loading?: boolean;
children: ReactNode;
@@ -431,10 +437,15 @@ export function ProviderSubscriptionCard({
return (
<OnboardingLoginCard
loading={loading}
instruction={
instruction={failure ? (failure === "expired" ? "This sign-in expired" : "Sign-in didn’t finish") : (
<>
<a
href={authorizationUrl}
onClick={event => {
if (!onOpenAuthorization) return;
event.preventDefault();
onOpenAuthorization();
}}
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 hover:text-foreground"
@@ -446,8 +457,14 @@ export function ProviderSubscriptionCard({
? " then come back and enter authorization code"
: " by providing the authorization code below"}
</>
}
)}
>
{failure && (
<div className="flex flex-col items-start gap-3 px-2">
<p role="alert" className="text-sm text-muted-foreground">Retry sign-in to get a fresh code. Your setup is saved.</p>
{onRetry && <Button type="button" variant="outline" onClick={onRetry}>Retry sign-in</Button>}
</div>
)}
{children}
</OnboardingLoginCard>
);
@@ -3449,6 +3449,25 @@ describe("AgentConfigForm environment selector", () => {
expect(findButton(container, "Cancel")).toBeFalsy();
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).not.toHaveBeenCalled();
});
it.each(["claude_local", "codex_local"])("offers inline retry for %s when its host has no retry footer", async adapterType => {
const claude = adapterType === "claude_local";
const statusApi = claude ? mockAgentsApi.getClaudeSetupTokenLoginStatus : mockAgentsApi.getAdapterAuthLoginStatus;
statusApi.mockResolvedValueOnce({ sessionId: claude ? "claude-session-1" : "session-1", status: "failed", expiresAt: null });
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
await act(async () => renderFormView(root, createFormQueryClient(),
<AdapterLoginPanel companyId="company-1" adapterType={adapterType} environmentId="sandbox-1" chrome="onboarding" autoStart />));
await flushUntil(() => Boolean(findButton(container, "Retry sign-in")));
expect(container.textContent).toContain("Sign-in didn’t finish");
const startApi = claude ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin;
startApi.mockResolvedValueOnce({ sessionId: "retry-session", status: "starting", expiresAt: null });
await clickByText(container, "Retry sign-in");
await flushUntil(() => (container.textContent ?? "").includes("Sign in to"));
expect(startApi).toHaveBeenCalledTimes(2);
expect(findButton(container, "Retry sign-in")).toBeUndefined();
});
it("offers an apply-existing affordance when the status route reports a stored value", async () => {
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({
secretId: "secret-1",
+152 -55
View File
@@ -2544,17 +2544,12 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
// the completion failed, or the session failed or ran out of time. The pair
// of `onCodeSubmitted`, so a caller that showed work can stop showing it.
onSubmitFailed?: () => void;
onOpenAuthorization?: () => void;
onSubmitReady?: (submit: (() => void) | null) => void;
onRetryReady?: (retry: (() => void) | null) => void;
chrome?: AdapterLoginChrome;
/**
* The address the customer has to open, once the server has produced one.
*
* The one fact about a running login that the step needs outside the card:
* its own button is what sends the customer there, and a prompt arriving is
* what moves the step from waiting to ready. Everything else it needs the
* panel already does — the paste submits itself, and the submit and how it
* ended are reported through `onCodeSubmitted`, `onSubmitFailed` and
* `onConnected` — so this stays a single value rather than a whole session
* handed upward.
*/
onPromptReady?: (authorizationUrl: string | null) => void;
};
@@ -2607,8 +2602,11 @@ function DisplayedCodeLoginPanel({
chrome = "panel",
aiConnection,
onPromptReady,
onOpenAuthorization,
onRetryReady,
}: AdapterLoginPanelProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
const [startError, setStartError] = useState<string | null>(null);
// The server delivers the one-time prompt on the first owner read only. Latch
// it so a later poll that returns a null prompt does not hide the code and the
@@ -2696,7 +2694,7 @@ function DisplayedCodeLoginPanel({
const statusQuery = useQuery({
queryKey: ["adapter-login-status", companyId, adapterType, sessionId],
queryFn: () => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!),
enabled: Boolean(sessionId) && !releasingResumedSession,
enabled: Boolean(sessionId) && !releasingResumedSession && !retrying,
// A status 404 is unrecoverable: the server removed the row, so a retry
// cannot bring it back. The shared retry policy never retries a 4xx, so
// it stops at once and fails loudly; only transient errors are retried.
@@ -2712,14 +2710,14 @@ function DisplayedCodeLoginPanel({
// returns a null prompt after the one-time delivery, so keep the latched value.
useEffect(() => {
const next = statusQuery.data?.prompt ?? null;
if (next) setLatchedPrompt(next);
}, [statusQuery.data]);
if (next && !retrying) setLatchedPrompt(next);
}, [statusQuery.data, retrying]);
const session = statusQuery.data ?? startLogin.data ?? null;
const status = session?.status ?? null;
const prompt = latchedPrompt;
const isTerminal = status ? ADAPTER_LOGIN_TERMINAL_STATUSES.has(status) : false;
const isActive = Boolean(sessionId) && !isTerminal;
const isActive = Boolean(sessionId) && !isTerminal && !retrying;
// A saving bind also blocks a new Sign in: the bind is an agent-update save,
// and a second login started while it is in flight could finish its own
// save first — the older save would then land last and silently revert the
@@ -2752,7 +2750,7 @@ function DisplayedCodeLoginPanel({
useEffect(() => {
const error = statusQuery.error;
if (!(error instanceof ApiError && error.status === 404)) return;
if (!resumedRef.current || releasingResumedSession) return;
if (chrome === "onboarding" || !resumedRef.current || releasingResumedSession) return;
setReleasingResumedSession(true);
const id = sessionId;
void (async () => {
@@ -2764,7 +2762,7 @@ function DisplayedCodeLoginPanel({
setReleasingResumedSession(false);
clearActiveSession();
})();
}, [statusQuery.error, releasingResumedSession, sessionId, companyId, adapterType, clearActiveSession]);
}, [statusQuery.error, releasingResumedSession, sessionId, companyId, adapterType, clearActiveSession, chrome]);
// Start once, on mount, when the caller has already taken the press, and
// only once the resume read has answered: a resumed session takes over
@@ -2870,36 +2868,74 @@ function DisplayedCodeLoginPanel({
onPromptReadyRef.current?.(prompt?.url ?? null);
}, [prompt]);
const failed = Boolean(isTerminal && status !== "authenticated") || statusQuery.isError;
const retryingRef = useRef(false);
const retryLogin = useMutation({
mutationFn: async () => {
if (sessionId) {
await agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId).catch(error => {
if (!(error instanceof ApiError && error.status === 404)) throw error;
});
}
const active = await activeSessionQuery.refetch({ throwOnError: true });
clearActiveSession();
startLogin.reset();
if (active.data) {
resumedRef.current = true;
setLatchedPrompt(active.data.prompt ?? null);
setSessionId(active.data.sessionId);
} else {
await startLogin.mutateAsync();
}
},
onMutate: () => {
setStartError(null);
setLatchedPrompt(null);
},
onError: error => setStartError(describeError(error).body),
onSettled: () => {
retryingRef.current = false;
setRetrying(false);
},
});
const retryAction = () => {
if (retryingRef.current) return;
retryingRef.current = true;
setRetrying(true);
retryLogin.mutate();
};
const retryActionRef = useRef(retryAction);
retryActionRef.current = retryAction;
const onRetryReadyRef = useRef(onRetryReady);
onRetryReadyRef.current = onRetryReady;
const retryReady = Boolean(startError || failed) && !retryLogin.isPending && !startLogin.isPending;
useEffect(() => {
onRetryReadyRef.current?.(retryReady ? () => retryActionRef.current() : null);
return () => onRetryReadyRef.current?.(null);
}, [retryReady]);
if (chrome === "onboarding") {
const failed = isTerminal && status && status !== "authenticated";
const failure = !retryLogin.isPending && (startError || failed)
? status === "timed_out" ? "expired" : "failed"
: undefined;
return (
<ProviderSubscriptionCard
loading={!prompt && !startError && !failed}
loading={retryLogin.isPending || (!prompt && !failure)}
failure={failure}
onRetry={!onRetryReady && retryReady ? retryAction : undefined}
providerName={connectSourceName(adapterType)}
authorizationUrl={prompt?.url}
onOpenAuthorization={onOpenAuthorization}
mode="displayed_code"
>
{startError ? (
<div>
<p role="alert" className="pl-2 text-xs text-destructive">{startError}</p>
{activeSessionQuery.error instanceof AdapterLoginConflictError && (
<Button type="button" variant="outline" disabled={cancelConflictingLogin.isPending}
onClick={() => cancelConflictingLogin.mutate()}>
Cancel previous sign-in and retry
</Button>
)}
</div>
) : failed ? (
<p role="alert" className="pl-2 text-xs text-destructive">
{status === "timed_out"
? "The login timed out. Start it again."
: status === "cancelled"
? "The login was cancelled."
: "The login did not finish. Start it again."}
</p>
) : (
<OnboardingLoginCodeRow code={prompt?.code ?? ""} autoCopy />
)}
{failure ? (
activeSessionQuery.error instanceof AdapterLoginConflictError && (
<Button type="button" variant="outline" disabled={cancelConflictingLogin.isPending}
onClick={() => cancelConflictingLogin.mutate()}>
Cancel previous sign-in and retry
</Button>
)
) : !retryLogin.isPending && <OnboardingLoginCodeRow code={prompt?.code ?? ""} autoCopy />}
</ProviderSubscriptionCard>
);
}
@@ -3102,8 +3138,12 @@ function SubmittedBrowserCodeLoginPanel({
chrome = "panel",
aiConnection,
onPromptReady,
onOpenAuthorization,
onSubmitReady,
onRetryReady,
}: AdapterLoginPanelProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
const [startError, setStartError] = useState<string | null>(null);
// The server delivers the authorization URL on the guarded prompt read only.
// Latch it so a later poll does not hide the URL.
@@ -3127,6 +3167,7 @@ function SubmittedBrowserCodeLoginPanel({
// or a failure. The field is locked for that stretch: the step's button is
// saying "Connecting" above it, and a second paste would submit again.
const [codeSubmitted, setCodeSubmitted] = useState(false);
const codeSubmittedRef = useRef(false);
// True after the status poll returns 404. The server removes the row and the
// in-memory session at once on any non-stored terminal state, so a status 404
// means the login failed and the server cleaned up. The panel stops both
@@ -3156,6 +3197,7 @@ function SubmittedBrowserCodeLoginPanel({
setTimedOut(false);
setStatusGone(false);
setCodeSubmitted(false);
codeSubmittedRef.current = false;
completionStartedRef.current = false;
};
@@ -3291,7 +3333,7 @@ function SubmittedBrowserCodeLoginPanel({
// status 404 also stops the polls: the server cleaned up the session, so the
// panel enters a terminal failure state instead.
const pollingEnabled =
Boolean(sessionId) && !timedOut && !statusGone && !releasingResumedSession;
Boolean(sessionId) && !timedOut && !statusGone && !releasingResumedSession && !retrying;
const statusQuery = useQuery({
queryKey: ["claude-setup-token-status", companyId, sessionId],
@@ -3324,7 +3366,7 @@ function SubmittedBrowserCodeLoginPanel({
useEffect(() => {
const error = statusQuery.error;
if (!(error instanceof ApiError && error.status === 404)) return;
if (resumedRef.current) {
if (resumedRef.current && chrome !== "onboarding") {
if (releasingResumedSession) return;
setReleasingResumedSession(true);
const id = sessionId;
@@ -3340,7 +3382,7 @@ function SubmittedBrowserCodeLoginPanel({
return;
}
setStatusGone(true);
}, [statusQuery.error, releasingResumedSession, sessionId, companyId]);
}, [statusQuery.error, releasingResumedSession, sessionId, companyId, chrome]);
// Adopt the caller's active session once, on mount. This is what makes a
// page reload keep the session: with no local state at all, the panel would
@@ -3381,11 +3423,11 @@ function SubmittedBrowserCodeLoginPanel({
useEffect(() => {
const url = promptQuery.data?.authorizationUrl ?? null;
if (url) setAuthorizationUrl(url);
if (url && !retrying) setAuthorizationUrl(url);
// The advisory rides the same guarded prompt read as the URL. Latch it, so a
// later poll does not clear the disclaimer.
if (promptQuery.data?.transportAdvisory) setTransportInsecure(true);
}, [promptQuery.data]);
}, [promptQuery.data, retrying]);
const submitCode = useMutation({
mutationFn: (code: string) =>
@@ -3431,7 +3473,7 @@ function SubmittedBrowserCodeLoginPanel({
statusGone ||
Boolean(status && CLAUDE_LOGIN_FAILURE_STATUSES.has(status));
const isCompleting = status === "authenticated" && !isStored && !completionFailed;
const isActive = Boolean(sessionId) && !isStored && !isFailure && !timedOut;
const isActive = Boolean(sessionId) && !isStored && !isFailure && !timedOut && !retrying;
const startDisabled = startLogin.isPending || isActive;
// Cap the active login at the server deadline. The timer arms when the login
@@ -3478,7 +3520,8 @@ function SubmittedBrowserCodeLoginPanel({
onSubmitFailedRef.current = onSubmitFailed;
const handleSubmit = () => {
if (!canSubmit) return;
if (!canSubmit || codeSubmittedRef.current) return;
codeSubmittedRef.current = true;
// A new attempt supersedes the last attempt's error, and has to: the
// failure report below watches for an error after a submit, and one left
// over from before it would end this attempt the moment it began.
@@ -3560,6 +3603,13 @@ function SubmittedBrowserCodeLoginPanel({
*/
const handleSubmitRef = useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
const onSubmitReadyRef = useRef(onSubmitReady);
onSubmitReadyRef.current = onSubmitReady;
const submitReady = canSubmit && !startError;
useEffect(() => {
onSubmitReadyRef.current?.(submitReady ? () => handleSubmitRef.current() : null);
return () => onSubmitReadyRef.current?.(null);
}, [submitReady]);
const autoSubmit = chrome === "onboarding";
const pastedRef = useRef(false);
useEffect(() => {
@@ -3590,6 +3640,7 @@ function SubmittedBrowserCodeLoginPanel({
if (!codeSubmitted) return;
if (!startError && !isFailure && !timedOut) return;
setCodeSubmitted(false);
codeSubmittedRef.current = false;
onSubmitFailedRef.current?.();
}, [codeSubmitted, startError, isFailure, timedOut]);
@@ -3599,13 +3650,67 @@ function SubmittedBrowserCodeLoginPanel({
onPromptReadyRef.current?.(authorizationUrl);
}, [authorizationUrl]);
const retryingRef = useRef(false);
const retryLogin = useMutation({
mutationFn: async () => {
if (sessionId) {
await agentsApi.cancelClaudeSetupTokenLogin(companyId, sessionId).catch(error => {
if (!(error instanceof ApiError && error.status === 404)) throw error;
});
}
const active = await activeSessionQuery.refetch({ throwOnError: true });
clearActiveSession();
startLogin.reset();
submitCode.reset();
completeLogin.reset();
if (active.data) {
resumedRef.current = true;
setSessionId(active.data.sessionId);
setAuthorizationUrl(active.data.prompt?.authorizationUrl ?? null);
setTransportInsecure(Boolean(active.data.prompt?.transportAdvisory));
} else {
await startLogin.mutateAsync();
}
},
onMutate: () => {
setStartError(null);
setAuthorizationUrl(null);
},
onError: error => setStartError(describeError(error).body),
onSettled: () => {
retryingRef.current = false;
setRetrying(false);
},
});
const retryAction = () => {
if (retryingRef.current) return;
retryingRef.current = true;
setRetrying(true);
retryLogin.mutate();
};
const retryActionRef = useRef(retryAction);
retryActionRef.current = retryAction;
const onRetryReadyRef = useRef(onRetryReady);
onRetryReadyRef.current = onRetryReady;
const onboardingFailed = Boolean(startError || isFailure || timedOut || statusQuery.isError || promptQuery.isError);
const retryReady = onboardingFailed && !retryLogin.isPending && !startLogin.isPending;
useEffect(() => {
onRetryReadyRef.current?.(retryReady ? () => retryActionRef.current() : null);
return () => onRetryReadyRef.current?.(null);
}, [retryReady]);
if (chrome === "onboarding") {
const failedNow = isFailure || timedOut;
const failure = !retryLogin.isPending && onboardingFailed
? timedOut || status === "timed_out" ? "expired" : "failed"
: undefined;
return (
<ProviderSubscriptionCard
loading={!authorizationUrl && !startError && !failedNow}
loading={retryLogin.isPending || (!authorizationUrl && !failure)}
failure={failure}
onRetry={!onRetryReady && retryReady ? retryAction : undefined}
providerName={connectSourceName(adapterType)}
authorizationUrl={authorizationUrl ?? undefined}
onOpenAuthorization={onOpenAuthorization}
mode="submitted_code"
>
{/* The plain-HTTP advisory survives the redesign. It is the one thing on
@@ -3619,15 +3724,7 @@ function SubmittedBrowserCodeLoginPanel({
network. Continue only on a network you trust.
</p>
)}
{startError ? (
<p role="alert" className="pl-2 text-xs text-destructive">
{startError}
</p>
) : failedNow ? (
<p role="alert" className="pl-2 text-xs text-destructive">
{timedOut && !isFailure ? CLAUDE_LOGIN_TIMED_OUT_MESSAGE : CLAUDE_LOGIN_FAILED_MESSAGE}
</p>
) : (
{!failure && !retryLogin.isPending && (
<OnboardingCardField
value={browserCode}
onChange={setBrowserCode}
+280 -4
View File
@@ -397,6 +397,12 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockAgentsApi.getActiveAdapterAuthLoginSession.mockRejectedValue(
new ApiError("Not found", 404, null),
);
for (const mock of [mockAgentsApi.startClaudeSetupTokenLogin, mockAgentsApi.getClaudeSetupTokenLoginStatus,
mockAgentsApi.getClaudeSetupTokenLoginPrompt, mockAgentsApi.cancelClaudeSetupTokenLogin,
mockAgentsApi.submitClaudeSetupTokenBrowserCode, mockAgentsApi.completeClaudeSetupTokenLogin,
mockAgentsApi.startAdapterAuthLogin, mockAgentsApi.getAdapterAuthLoginStatus, mockAgentsApi.cancelAdapterAuthLogin]) {
mock.mockReset();
}
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockReset();
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue(
new ApiError("Not found", 404, null),
@@ -2873,6 +2879,131 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
it.each([
["claude_local", /Claude/, "claude.ai", "link"],
["claude_local", /Claude/, "claude.ai", "button"],
["codex_local", /OpenAI/, "auth.openai.com", "link"],
["codex_local", /OpenAI/, "auth.openai.com", "button"],
] as const)("waits for the code after the %s %s %s %s opens sign-in", async (adapterType, source, host, control) => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType });
await pickSource(source);
const open = vi.spyOn(window, "open").mockImplementation(() => null);
try {
const link = document.body.querySelector(`a[href*="${host}"]`) as HTMLAnchorElement;
expect(link.target).toBe("_blank");
await act(async () => {
if (control === "link") link.click();
else [...document.body.querySelectorAll("button")].at(-1)!.click();
});
expect(open).toHaveBeenCalledExactlyOnceWith(link.href, "_blank", "noreferrer,noopener");
const primary = [...document.body.querySelectorAll("button")].at(-1)!;
expect(primary.textContent?.trim()).toBe("Waiting for code…");
expect(primary.disabled).toBe(true);
await act(async () => primary.click());
await act(async () => document.querySelector('[data-testid="onboarding-wizard"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true, cancelable: true })));
expect(open).toHaveBeenCalledTimes(1);
await act(async () => link.click());
expect(open).toHaveBeenNthCalledWith(2, link.href, "_blank", "noreferrer,noopener");
expect(primary.textContent?.trim()).toBe("Waiting for code…");
expect(primary.disabled).toBe(true);
if (adapterType === "claude_local") expect((document.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement).disabled).toBe(false);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(adapterType === "claude_local" ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
} finally {
open.mockRestore();
await act(async () => root.unmount());
}
});
it.each([false, true])("submits a code inserted without a paste event from the footer (opened link: %s)", async openedLink => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const open = vi.spyOn(window, "open").mockImplementation(() => null);
try {
if (openedLink) await act(async () => (document.body.querySelector('a[href*="claude.ai"]') as HTMLAnchorElement).click());
open.mockClear();
const field = document.body.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
await act(async () => setControlledValue(field, "authorization-code"));
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).not.toHaveBeenCalled();
const primary = [...document.body.querySelectorAll("button")].at(-1)!;
expect(primary.textContent?.trim()).toBe("Submit code");
expect(primary.disabled).toBe(false);
await act(async () => { primary.click(); primary.click(); });
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1", "authorization-code");
expect(open).not.toHaveBeenCalled();
expect([...document.body.querySelectorAll("button")].at(-1)!.textContent?.trim()).toBe("Connecting");
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally {
open.mockRestore();
await act(async () => root.unmount());
}
});
it.each([{}, { metaKey: true }, { ctrlKey: true }])("submits the code once with Enter from its field (%j)", async modifier => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const open = vi.spyOn(window, "open").mockImplementation(() => null);
try {
open.mockClear();
const field = document.body.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
await act(async () => setControlledValue(field, "authorization-code"));
await act(async () => field.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...modifier })));
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1", "authorization-code");
expect(open).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally {
open.mockRestore();
await act(async () => root.unmount());
}
});
it.each([{ metaKey: true }, { ctrlKey: true }])("submits the code with the wizard shortcut while waiting (%j)", async modifier => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const open = vi.spyOn(window, "open").mockImplementation(() => null);
try {
await act(async () => (document.body.querySelector('a[href*="claude.ai"]') as HTMLAnchorElement).click());
open.mockClear();
const field = document.body.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
await act(async () => setControlledValue(field, "authorization-code"));
const wizard = document.querySelector('[data-testid="onboarding-wizard"]')!;
await act(async () => wizard.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...modifier })));
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1", "authorization-code");
expect(open).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally {
open.mockRestore();
await act(async () => root.unmount());
}
});
it("withdraws the footer submit action when the code is cleared or the card is closed", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const field = document.body.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
await act(async () => (document.body.querySelector('a[href*="claude.ai"]') as HTMLAnchorElement).click());
await act(async () => setControlledValue(field, "authorization-code"));
const primary = () => [...document.body.querySelectorAll("button")].at(-1)!;
expect(primary().textContent?.trim()).toBe("Submit code");
await act(async () => setControlledValue(field, ""));
expect(primary().textContent?.trim()).toBe("Waiting for code…");
expect(primary().disabled).toBe(true);
await act(async () => setControlledValue(field, "authorization-code"));
const back = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!;
await act(async () => back.click());
for (let i = 0; i < 10; i++) await flushReact();
expect(primary().textContent?.trim()).toBe("Next");
expect(primary().disabled).toBe(true);
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it("submits the browser code on the paste, not on the first keystroke", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
@@ -2986,13 +3117,157 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
for (let i = 0; i < 8; i++) await flushReact();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("That authorization code was not accepted.");
expect(cta()).toBe("Sign in to Claude");
expect(document.body.textContent).toContain("Sign-in didn’t finish");
expect(document.body.textContent).not.toContain("That authorization code was not accepted.");
expect(cta()).toBe("Retry sign-in");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it.each(["code", "start", "lookup", "prompt", "status", "failed", "timed_out", "gone", "completion"] as const)("retries Claude sign-in after a %s failure", async failure => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
if (failure === "code") mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockRejectedValueOnce(new Error("Private provider detail"));
if (failure === "start") mockAgentsApi.startClaudeSetupTokenLogin.mockRejectedValueOnce(new Error("Sandbox unavailable"));
if (failure === "lookup") mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue(new Error("Lookup unavailable"));
if (failure === "gone" || failure === "status") mockAgentsApi.getClaudeSetupTokenLoginStatus.mockRejectedValueOnce(new ApiError("Session unavailable", failure === "gone" ? 404 : 503, {}));
if (failure === "prompt") mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockRejectedValueOnce(new ApiError("Sandbox unavailable", 503, {}));
if (failure === "failed" || failure === "timed_out" || failure === "completion") {
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValueOnce({
sessionId: "claude-session-1", status: failure === "completion" ? "authenticated" : failure,
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
}
if (failure === "completion") mockAgentsApi.completeClaudeSetupTokenLogin.mockRejectedValueOnce(new Error("Completion unavailable"));
const { root } = await openStep4({ adapterType: "claude_local" });
try {
await pickSource(/Claude/);
if (failure === "code") {
const field = document.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
await act(async () => {
field.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field, "expired-code");
});
}
for (let i = 0; i < 8; i++) await flushReact();
const primary = () => [...document.body.querySelectorAll("button")].at(-1)!;
expect(document.body.textContent).toContain(failure === "timed_out" ? "This sign-in expired" : "Sign-in didn’t finish");
expect(document.querySelector('[role="alert"]')?.className).not.toContain("text-destructive");
expect(document.body.textContent).toContain("Retry sign-in to get a fresh code. Your setup is saved.");
expect(document.body.textContent).not.toContain("Private provider detail");
expect(document.querySelector('a[href*="claude.ai"]')).toBeNull();
expect(document.querySelector('input[aria-label="Authorization code"]')).toBeNull();
expect(primary().textContent?.trim()).toBe("Retry sign-in");
expect(primary().disabled).toBe(false);
const startsBeforeRetry = mockAgentsApi.startClaudeSetupTokenLogin.mock.calls.length;
let release: () => void = () => {};
if (failure !== "start" && failure !== "lookup") mockAgentsApi.cancelClaudeSetupTokenLogin.mockImplementationOnce(() => new Promise(resolve => { release = () => resolve({}); }));
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue(new ApiError("Not found", 404, null));
mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValueOnce({
sessionId: "claude-session-2", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-2", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockResolvedValue({ authorizationUrl: "https://claude.ai/oauth/authorize?attempt=2", transportAdvisory: null });
await act(async () => { primary().click(); primary().click(); });
if (failure !== "start" && failure !== "lookup") {
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1");
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(startsBeforeRetry);
expect(primary().disabled).toBe(true);
await act(async () => release());
}
for (let i = 0; i < 12; i++) await flushReact();
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(startsBeforeRetry + 1);
expect(primary().textContent?.trim()).toBe("Sign in to Claude");
const field = document.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
expect(field.value).toBe("");
expect(document.querySelector('[role="alert"]')).toBeNull();
await act(async () => setControlledValue(field, "fresh-code"));
await act(async () => primary().click());
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenLastCalledWith("company-new", "claude-session-2", "fresh-code");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally {
await act(async () => root.unmount());
}
});
it.each(["claude_local", "codex_local"] as const)("keeps a missing resumed %s session retryable", async adapterType => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const claude = adapterType === "claude_local";
const active = {
sessionId: claude ? "claude-session-1" : "codex-session-1", environmentId: "env-sandbox-1",
status: "waiting_for_user", expiresAt: null, failure: null,
aiConnection: { provider: claude ? "anthropic" : "openai", method: "subscription", ownership: "personal", agentIds: [], allAgents: true },
panelMode: claude ? "submitted_browser_code" : "displayed_code",
prompt: claude ? { authorizationUrl: "https://claude.ai/oauth/authorize?code=true" } : { url: "https://auth.openai.com/codex/device", code: "OLD-CODE" },
};
const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession;
activeApi.mockResolvedValue(active);
const statusApi = claude ? mockAgentsApi.getClaudeSetupTokenLoginStatus : mockAgentsApi.getAdapterAuthLoginStatus;
statusApi.mockRejectedValueOnce(new ApiError("Session gone", 404, {}));
const { root } = await openStep4({ adapterType });
try {
for (let i = 0; i < 10; i++) await flushReact();
const primary = () => [...document.body.querySelectorAll("button")].at(-1)!;
expect(primary().textContent?.trim()).toBe("Retry sign-in");
expect(document.body.textContent).toContain("Sign-in didn’t finish");
activeApi.mockRejectedValue(new ApiError("Not found", 404, null));
await act(async () => primary().click());
for (let i = 0; i < 12; i++) await flushReact();
expect(primary().textContent?.trim()).toBe(claude ? "Sign in to Claude" : "Sign in to OpenAI");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally {
await act(async () => root.unmount());
}
});
it("keeps retry available after cancellation fails, and accepts an already-removed session", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValueOnce({ sessionId: "claude-session-1", status: "failed", expiresAt: new Date(Date.now() + 600_000).toISOString() });
mockAgentsApi.cancelClaudeSetupTokenLogin.mockRejectedValueOnce(new Error("Temporary error"))
.mockRejectedValueOnce(new ApiError("Session gone", 404, {}));
const { root } = await openStep4({ adapterType: "claude_local" });
try {
await pickSource(/Claude/);
const primary = () => [...document.body.querySelectorAll("button")].at(-1)!;
await act(async () => primary().click());
for (let i = 0; i < 8; i++) await flushReact();
expect(primary().textContent?.trim()).toBe("Retry sign-in");
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValueOnce({ sessionId: "claude-session-2", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString() });
await act(async () => primary().click());
for (let i = 0; i < 12; i++) await flushReact();
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(2);
expect(primary().textContent?.trim()).toBe("Sign in to Claude");
} finally {
await act(async () => root.unmount());
}
});
it.each(["timed_out", "gone"] as const)("retries OpenAI sign-in after the session is %s", async failure => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
if (failure === "gone") mockAgentsApi.getAdapterAuthLoginStatus.mockRejectedValueOnce(new ApiError("Session gone", 404, {}));
else mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValueOnce({ sessionId: "codex-session-1", status: "timed_out", prompt: { url: "https://auth.openai.com/codex/device", code: "OLD-CODE" } });
const { root } = await openStep4({ adapterType: "codex_local" });
try {
await pickSource(/OpenAI/);
const primary = () => [...document.body.querySelectorAll("button")].at(-1)!;
expect(primary().textContent?.trim()).toBe("Retry sign-in");
mockAgentsApi.startAdapterAuthLogin.mockResolvedValueOnce({ sessionId: "codex-session-2", status: "pending" });
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({ sessionId: "codex-session-2", status: "pending", prompt: { url: "https://auth.openai.com/codex/device", code: "FRESH-CODE" } });
await act(async () => { primary().click(); primary().click(); });
for (let i = 0; i < 12; i++) await flushReact();
expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenCalledExactlyOnceWith("company-new", "codex_local", "codex-session-1");
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(2);
expect(primary().textContent?.trim()).toBe("Sign in to OpenAI");
expect(document.body.textContent).toContain("FRESH-CODE");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally {
await act(async () => root.unmount());
}
});
it("does not reopen the card when a pasted code fails after Back", async () => {
// The panel stays mounted through Back's exit, so its report of a failed
// submit can land mid-exit. Restoring the button there reopened the card
@@ -3451,7 +3726,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await pickSource(/OpenAI/);
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Paperclip is temporarily unavailable");
expect(document.body.textContent).toContain("Sign-in didn’t finish");
await act(async () => root.unmount());
});
@@ -3467,7 +3742,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await pickSource(/Claude/);
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Paperclip is temporarily unavailable");
expect(document.body.textContent).toContain("Sign-in didn’t finish");
await act(async () => root.unmount());
});
@@ -4281,6 +4556,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
expiresAt: null,
failure: null,
panelMode: "submitted_browser_code",
aiConnection: { provider: "anthropic", method: "subscription", ownership: "personal", agentIds: [], allAgents: true },
prompt: { authorizationUrl: "https://claude.ai/oauth/authorize?code=true" },
});
const { root } = await openStep4({ adapterType: "claude_local" });
+36 -5
View File
@@ -688,6 +688,8 @@ function OnboardingWizardInner({
* browser. Its arrival is also what moves the step off its waiting beat.
*/
const [connectAuthUrl, setConnectAuthUrl] = useState<string | null>(null);
const [retryLogin, setRetryLogin] = useState<(() => void) | null>(null);
const [submitLoginCode, setSubmitLoginCode] = useState<(() => void) | null>(null);
/**
* The key itself, held only for as long as the wizard is open. It is written
* into the adapter config at hire time and never into the draft — a draft is
@@ -1558,10 +1560,13 @@ function OnboardingWizardInner({
*/
const connectSourceLabel = CONNECT_SOURCE_NAMES[adapterType] ?? adapterType;
const connectCta: { label: string; icon: FooterPrimaryIcon; disabled: boolean } =
setupError ? { label: "Retry", icon: "arrow", disabled: false } : connectProgress
setupError ? { label: "Retry", icon: "arrow", disabled: false }
: retryLogin ? { label: "Retry sign-in", icon: "arrow", disabled: false } : connectProgress
? { label: adapterEnvLoading ? "Testing…" : connectProgress, icon: "spinner", disabled: true }
: (connectPhase === "ready" || connectPhase === "waiting") && submitLoginCode
? { label: "Submit code", icon: "arrow", disabled: false }
: connectPhase === "waiting"
? { label: "Waiting for code", icon: "spinner", disabled: true }
? { label: "Waiting for code…", icon: "spinner", disabled: true }
: connectPhase === "connecting"
? { label: "Connecting", icon: "spinner", disabled: true }
: connectPhase === "ready"
@@ -1601,6 +1606,8 @@ function OnboardingWizardInner({
setLoading(false);
setAdapterEnvLoading(false);
setConnectAuthUrl(null);
setSubmitLoginCode(null);
setRetryLogin(null);
connectingSinceRef.current = null;
setConnectCredentialStored(false);
// Where the reverse starts depends on how far the sequence got. Backing out
@@ -1618,12 +1625,31 @@ function OnboardingWizardInner({
* a check. This one would be worse: Cmd+Enter would hire before the sign-in
* it is meant to start, against a source with no credential.
*/
function openConnectAuthorization() {
if ((connectPhase !== "ready" && connectPhase !== "waiting") || !connectAuthUrl || retryLogin) return;
window.open(connectAuthUrl, "_blank", "noreferrer,noopener");
setConnectPhase("waiting");
}
function handleConnectStepPrimary() {
if (setupError) { setSetupError(false); setSetupAttempt(value => value + 1); setError(null); return; }
if (retryLogin) {
setConnectAuthUrl(null);
setSubmitLoginCode(null);
setRetryLogin(null);
connectingSinceRef.current = null;
setConnectCredentialStored(false);
setConnectPhase("loading");
retryLogin();
return;
}
if ((connectPhase === "ready" || connectPhase === "waiting") && submitLoginCode) {
submitLoginCode();
return;
}
// Mid-sequence the button belongs to the sign-in, not to the step.
if (connectPhase === "ready" && connectStepNeedsLogin) {
if (connectAuthUrl) window.open(connectAuthUrl, "_blank", "noreferrer,noopener");
setConnectPhase("waiting");
openConnectAuthorization();
return;
}
// The hold owns the hire once "Connecting" is showing. That now starts at
@@ -2647,7 +2673,7 @@ function OnboardingWizardInner({
step === 4 &&
agentName.trim() &&
connectStepReady &&
!connectStepLoggingIn
(retryLogin || submitLoginCode || !connectStepLoggingIn)
)
handleConnectStepPrimary();
else if (step === 5) handleLaunchToDashboard();
@@ -3174,6 +3200,11 @@ function OnboardingWizardInner({
// The prompt arriving is what ends the waiting beat.
if (url) setConnectPhase((p) => (p === "loading" ? "ready" : p));
}}
onOpenAuthorization={openConnectAuthorization}
onRetryReady={retry => {
if (connectCardLive) setRetryLogin(() => retry);
}}
onSubmitReady={submit => setSubmitLoginCode(() => submit)}
onCodeSubmitted={() => {
// The button reacts to the paste, not to the server.
// Waiting for the login to be stored left about a
@@ -43,7 +43,7 @@ const meta = {
],
"implementation": "production",
"boundary": "fixtures",
"notes": "Production wizard with simulated accounts, immutable-target discovery, native/Legacy setup and refused/unconfirmed cleanup. Fixtures do not prove isolation, lease cleanup, runtime limits, draft reload persistence or model execution."
"notes": "Production wizard with simulated accounts, failed/expired sign-in and retry, immutable-target discovery, native/Legacy setup and refused/unconfirmed cleanup. Fixtures do not prove isolation, lease cleanup, runtime limits, draft reload persistence or model execution."
},
},
} satisfies Meta;
@@ -382,14 +382,16 @@ function signedOutConnectionFixture() {
return () => { clearOnboardingDraft(); resetOnboardingFixtureState(); };
}
async function openProviderConnection(provider: "Claude" | "OpenAI", mode: "subscription" | "api") {
async function openProviderConnection(provider: "Claude" | "OpenAI", mode: "subscription" | "api", loginState: "ready" | "failed" = "ready") {
await advance("Connect a model");
await userEvent.click(await screen.findByRole("button", { name: "Use API key instead" }, { timeout: STEP_TIMEOUT_MS }));
if (mode === "subscription") {
await userEvent.click(await screen.findByRole("button", { name: "Use subscription instead" }, { timeout: STEP_TIMEOUT_MS }));
}
await userEvent.click(await screen.findByRole("radio", { name: new RegExp(`^${provider} `) }, { timeout: STEP_TIMEOUT_MS }));
if (mode === "api") {
if (loginState === "failed") {
await screen.findByRole("button", { name: "Retry sign-in" }, { timeout: STEP_TIMEOUT_MS });
} else if (mode === "api") {
await screen.findByLabelText("API key", {}, { timeout: STEP_TIMEOUT_MS });
} else if (provider === "Claude") {
await screen.findByLabelText("Authorization code", {}, { timeout: STEP_TIMEOUT_MS });
@@ -404,6 +406,78 @@ export const ClaudeSubscription: StoryObj = {
render: () => <WizardArc />,
play: () => openProviderConnection("Claude", "subscription"),
};
function claudeLoginFailureStory(failure: "failed" | "timed_out"): StoryObj {
return {
name: failure === "timed_out" ? "Connect · Claude sign-in expired" : "Connect · Claude sign-in failed",
beforeEach: () => {
const reset = signedOutConnectionFixture();
const previous = window.fetch;
const base = `/api/companies/${STORYBOOK_COMPANY_ID}/setup-token-login-sessions`;
let attempts = 0;
let setupAgent: Record<string, unknown> | null = null;
const released = new Set<string>();
const fixtureFetch: typeof fetch = async (input, init) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin);
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
const session = (id: string, status: string) => ({
sessionId: id, environmentId: "environment-storybook-sandbox", status,
expiresAt: new Date(Date.now() + 600_000).toISOString(), failure: null,
});
if (url.pathname === `/api/companies/${STORYBOOK_COMPANY_ID}/agents`) {
if (method === "GET") return Response.json(setupAgent ? [setupAgent] : []);
if (method === "POST") {
const body = JSON.parse(typeof init?.body === "string" ? init.body : input instanceof Request ? await input.clone().text() : "{}");
setupAgent = { ...storybookHiredAgent, ...body, id: body.setupId, status: "paused", pauseReason: "setup" };
return Response.json(setupAgent, { status: 201 });
}
}
if (setupAgent && url.pathname === `/api/agents/${setupAgent.id}` && url.searchParams.get("companyId") === STORYBOOK_COMPANY_ID) {
if (method === "PATCH") {
const body = JSON.parse(typeof init?.body === "string" ? init.body : input instanceof Request ? await input.clone().text() : "{}");
setupAgent = { ...setupAgent, ...body };
}
return Response.json(setupAgent);
}
if (url.pathname === base && method === "POST") {
if (attempts > 0 && !released.has(`claude-storybook-${attempts}`)) {
return Response.json({ error: "Release the previous attempt first." }, { status: 409 });
}
attempts += 1;
return Response.json(session(`claude-storybook-${attempts}`, "awaiting_browser_code"), { status: 201 });
}
if (url.pathname === `${base}/active`) return new Response(null, { status: 404 });
const match = url.pathname.slice(base.length).match(/^\/(claude-storybook-\d+)(?:\/(prompt|cancel))?$/);
if (url.pathname.startsWith(`${base}/`) && match) {
const id = match[1]!;
if (match[2] === "cancel" && method === "POST") {
released.add(id);
return Response.json(session(id, "cancelled"));
}
if (match[2] === "prompt") return Response.json({ authorizationUrl: `https://claude.example.test/authorize?attempt=${attempts}`, transportAdvisory: null });
return Response.json(session(id, id === "claude-storybook-1" ? failure : "awaiting_browser_code"));
}
return previous(input, init);
};
window.fetch = fixtureFetch;
return () => {
if (window.fetch === fixtureFetch) window.fetch = previous;
reset();
};
},
render: () => <WizardArc />,
play: async () => {
await openProviderConnection("Claude", "subscription", "failed");
await expect(screen.getByRole("button", { name: "Retry sign-in" })).toBeEnabled();
await expect(screen.getByRole("alert")).toHaveTextContent("Retry sign-in to get a fresh code. Your setup is saved.");
await expect(screen.queryByRole("link", { name: "Sign in to Claude" })).not.toBeInTheDocument();
await expect(screen.queryByLabelText("Authorization code")).not.toBeInTheDocument();
},
};
}
export const ClaudeSignInFailed = claudeLoginFailureStory("failed");
export const ClaudeSignInExpired = claudeLoginFailureStory("timed_out");
export const CodexSubscription: StoryObj = {
name: "Connect · Codex / OpenAI subscription",
beforeEach: signedOutConnectionFixture,