mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:12:25 +01:00
Cancel subscription sign-in when leaving setup
Back now dismisses automatic login resumption, aborts pending prompt and status reads, and releases abandoned sessions. Late start responses are canceled, and a replacement waits for earlier cleanup. Apply explicit cancellation to local attempts while preserving ordinary navigation and reload recovery. Verify with 465 UI tests, all six onboarding browser tests, UI typecheck, the shipped UI build, and token gates. The browser regression observes the pending prompt fetch abort after Back. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
02214b1a3c
commit
d20409e9a9
10 files changed
+278
-86
No files matched your search
@@ -41,6 +41,11 @@ the provider's API key, and Advanced. All three stay visible while the selected
|
||||
mode's form is shown below. Advanced opens the existing compatible-connection
|
||||
picker and provider setup. Execution environment selection lives in Configure.
|
||||
|
||||
Back from subscription sign-in cancels that attempt and pending prompt or status
|
||||
reads. A session returned after Back is released instead of reopening sign-in.
|
||||
Selecting a subscription again starts a fresh attempt. Reloading the page can
|
||||
still resume a sign-in that was not cancelled.
|
||||
|
||||
Storybook's simulated controllers and page annotations do not run in the app.
|
||||
|
||||
## Compatibility and selection
|
||||
|
||||
@@ -234,11 +234,12 @@ test.describe("Onboarding wizard", () => {
|
||||
// The sign-in Connect now starts. Stubbed so the card is deterministic:
|
||||
// the session start answers, and the guarded prompt read hands back an
|
||||
// authorization URL for the card's link row.
|
||||
let loginStarts = 0;
|
||||
await page.route("**/setup-token-login-sessions", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionId: "e2e-setup-token-session",
|
||||
sessionId: `e2e-setup-token-session-${++loginStarts}`,
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
}),
|
||||
@@ -253,11 +254,11 @@ test.describe("Onboarding wizard", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/setup-token-login-sessions/e2e-setup-token-session", (route) =>
|
||||
await page.route(/\/setup-token-login-sessions\/e2e-setup-token-session-\d+$/, (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionId: "e2e-setup-token-session",
|
||||
sessionId: route.request().url().split("/").at(-1),
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
}),
|
||||
@@ -343,6 +344,45 @@ test.describe("Onboarding wizard", () => {
|
||||
await expect(page.getByRole("button", { name: "Use saved login" })).toHaveCount(0);
|
||||
expect(hireCalled).toBe(false);
|
||||
|
||||
let cancellations = 0;
|
||||
await page.route("**/setup-token-login-sessions/*/cancel", async route => {
|
||||
cancellations++;
|
||||
await route.fulfill({ status: 204 });
|
||||
});
|
||||
const back = page.getByRole("button", { name: "Back", exact: true });
|
||||
await back.click();
|
||||
await expect(source).toHaveAttribute("aria-checked", "false");
|
||||
await expect.poll(() => cancellations).toBe(1);
|
||||
await page.evaluate(() => {
|
||||
const state = window as typeof window & { cancelledLoginPrompts: number };
|
||||
state.cancelledLoginPrompts = 0;
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.endsWith("/prompt")) init?.signal?.addEventListener("abort", () => { state.cancelledLoginPrompts++; }, { once: true });
|
||||
return fetch(input, init);
|
||||
};
|
||||
});
|
||||
let finishPrompt!: () => void;
|
||||
let promptStarted!: () => void;
|
||||
const pendingPrompt = new Promise<void>(resolve => { finishPrompt = resolve; });
|
||||
const promptRequest = new Promise<void>(resolve => { promptStarted = resolve; });
|
||||
await page.route("**/setup-token-login-sessions/*/prompt", async route => {
|
||||
promptStarted();
|
||||
await pendingPrompt;
|
||||
await route.fulfill({ json: { authorizationUrl: "https://claude.ai/oauth/authorize?late=true", transportAdvisory: null } }).catch(() => {});
|
||||
});
|
||||
await source.click();
|
||||
await promptRequest;
|
||||
await back.click();
|
||||
await expect.poll(() => page.evaluate(() => (window as typeof window & { cancelledLoginPrompts: number }).cancelledLoginPrompts)).toBe(1);
|
||||
finishPrompt();
|
||||
await expect(source).toHaveAttribute("aria-checked", "false");
|
||||
await expect.poll(() => cancellations).toBe(2);
|
||||
await expect(cardInstruction).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled();
|
||||
expect(hireCalled).toBe(false);
|
||||
|
||||
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
|
||||
});
|
||||
test("a reload during a login resumes the same session", async ({ page }) => {
|
||||
|
||||
+10
-8
@@ -338,18 +338,18 @@ export const agentsApi = {
|
||||
// The owner response repeats the live prompt on every read while the
|
||||
// session holds an active public status, so this polling call carries the
|
||||
// same no-store request option as the active-session read below.
|
||||
getAdapterAuthLoginStatus: (companyId: string, type: string, sessionId: string) =>
|
||||
getAdapterAuthLoginStatus: (companyId: string, type: string, sessionId: string, options?: { signal?: AbortSignal }) =>
|
||||
api.get<AdapterAuthSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store" },
|
||||
{ cache: "no-store", ...options },
|
||||
),
|
||||
// Reads the caller's active login session for one company and adapter, with
|
||||
// no session id, so the browser rediscovers its own session after a reload
|
||||
// with no local state. A 404 means no active session for the caller.
|
||||
getActiveAdapterAuthLoginSession: (companyId: string, type: string) =>
|
||||
getActiveAdapterAuthLoginSession: (companyId: string, type: string, options?: { signal?: AbortSignal }) =>
|
||||
api.get<AdapterAuthSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/active`,
|
||||
{ cache: "no-store" },
|
||||
{ cache: "no-store", ...options },
|
||||
),
|
||||
cancelAdapterAuthLogin: (companyId: string, type: string, sessionId: string) =>
|
||||
api.post<AdapterAuthSessionOwnerResponse>(
|
||||
@@ -378,21 +378,23 @@ export const agentsApi = {
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions`,
|
||||
{ adapterType: "claude_local", ...data },
|
||||
),
|
||||
getClaudeSetupTokenLoginStatus: (companyId: string, sessionId: string) =>
|
||||
getClaudeSetupTokenLoginStatus: (companyId: string, sessionId: string, options?: { signal?: AbortSignal }) =>
|
||||
api.get<ClaudeSetupTokenSessionResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}`,
|
||||
options,
|
||||
),
|
||||
// Reads the caller's active Claude setup-token login session, with no
|
||||
// session id, so the browser rediscovers its own session after a reload with
|
||||
// no local state. A 404 means no active session for the caller.
|
||||
getActiveClaudeSetupTokenLoginSession: (companyId: string) =>
|
||||
getActiveClaudeSetupTokenLoginSession: (companyId: string, options?: { signal?: AbortSignal }) =>
|
||||
api.get<ClaudeSetupTokenSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/active`,
|
||||
{ cache: "no-store" },
|
||||
{ cache: "no-store", ...options },
|
||||
),
|
||||
getClaudeSetupTokenLoginPrompt: (companyId: string, sessionId: string) =>
|
||||
getClaudeSetupTokenLoginPrompt: (companyId: string, sessionId: string, options?: { signal?: AbortSignal }) =>
|
||||
api.get<ClaudeSetupTokenSessionPrompt>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/prompt`,
|
||||
options,
|
||||
),
|
||||
submitClaudeSetupTokenBrowserCode: (companyId: string, sessionId: string, browserCode: string) =>
|
||||
api.post<ClaudeSetupTokenSessionResponse>(
|
||||
|
||||
@@ -2998,6 +2998,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
"company-1",
|
||||
"codex_local",
|
||||
"resumed-session-1",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(findButton(result.container, "Cancel")).toBeTruthy();
|
||||
});
|
||||
@@ -3769,6 +3770,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
expect(mockAgentsApi.getClaudeSetupTokenLoginStatus).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"resumed-claude-session-1",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(findButton(result.container, "Cancel")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAdapterLoginCancellation } from "./useAdapterLoginCancellation";
|
||||
import { ACCOUNT_BOUND_MODEL_DISCOVERY_QUERY_OPTIONS, isAccountBoundAcpxAgent } from "@/lib/native-model-catalog";
|
||||
import { useConnectionModels } from "./ai-connections/useConnectionModels";
|
||||
import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, normalizeClaudeRunnerOptions, type AgentRunnerChoice } from "@paperclipai/shared";
|
||||
@@ -2519,6 +2520,7 @@ export type AdapterLoginDescriptor = {
|
||||
// correctly, and the first thing to rot would have been the timeout and
|
||||
// cleanup paths, which are the ones nobody exercises by hand.
|
||||
export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
|
||||
active?: boolean;
|
||||
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
|
||||
onStored?: (storedSessionId: string) => void;
|
||||
onApplyStored?: () => void;
|
||||
@@ -2597,6 +2599,7 @@ class AdapterLoginConflictError extends Error {
|
||||
}
|
||||
|
||||
function DisplayedCodeLoginPanel({
|
||||
active: loginActive = true,
|
||||
companyId,
|
||||
adapterType,
|
||||
environmentId,
|
||||
@@ -2610,6 +2613,7 @@ function DisplayedCodeLoginPanel({
|
||||
onRetryReady,
|
||||
}: AdapterLoginPanelProps) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const cancellation = useAdapterLoginCancellation(loginActive, companyId, adapterType, false, sessionId);
|
||||
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
|
||||
@@ -2632,8 +2636,9 @@ function DisplayedCodeLoginPanel({
|
||||
const resumedRef = useRef(false);
|
||||
|
||||
const startLogin = useMutation({
|
||||
mutationFn: () => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId, aiConnection }),
|
||||
mutationFn: () => cancellation.accept(() => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId, aiConnection })),
|
||||
onSuccess: (session) => {
|
||||
if (!session || !cancellation.isActive()) return;
|
||||
resumedRef.current = false;
|
||||
setStartError(null);
|
||||
setLatchedPrompt(null);
|
||||
@@ -2643,6 +2648,7 @@ function DisplayedCodeLoginPanel({
|
||||
setSessionId(session.sessionId);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (!cancellation.isActive()) return;
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
@@ -2660,6 +2666,7 @@ function DisplayedCodeLoginPanel({
|
||||
mutationFn: () => agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId!),
|
||||
onSuccess: clearActiveSession,
|
||||
onError: (error) => {
|
||||
if (!cancellation.isActive()) return;
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
@@ -2669,9 +2676,9 @@ function DisplayedCodeLoginPanel({
|
||||
// 404 means no active session for the caller.
|
||||
const activeSessionQuery = useQuery({
|
||||
queryKey: ["adapter-login-active-session", companyId, adapterType],
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ signal }) => {
|
||||
try {
|
||||
const active = await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType);
|
||||
const active = await cancellation.accept(() => agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType, { signal }));
|
||||
if (!active) return null;
|
||||
if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new AdapterLoginConflictError(active.sessionId);
|
||||
return active;
|
||||
@@ -2687,6 +2694,7 @@ function DisplayedCodeLoginPanel({
|
||||
// in flight — which the per-owner cap then rejects.
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
enabled: loginActive,
|
||||
});
|
||||
const activeSessionView = useQueryView(activeSessionQuery);
|
||||
|
||||
@@ -2697,8 +2705,8 @@ function DisplayedCodeLoginPanel({
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["adapter-login-status", companyId, adapterType, sessionId],
|
||||
queryFn: () => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!),
|
||||
enabled: Boolean(sessionId) && !releasingResumedSession && !retrying,
|
||||
queryFn: ({ signal }) => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!, { signal }),
|
||||
enabled: loginActive && 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.
|
||||
@@ -2721,14 +2729,14 @@ function DisplayedCodeLoginPanel({
|
||||
const status = session?.status ?? null;
|
||||
const prompt = latchedPrompt;
|
||||
const isTerminal = status ? ADAPTER_LOGIN_TERMINAL_STATUSES.has(status) : false;
|
||||
const isActive = Boolean(sessionId) && !isTerminal && !retrying;
|
||||
const isActive = loginActive && 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
|
||||
// agent to the previous account while the panel reports the newer bind.
|
||||
// Serializing at the only entry point is the whole fix; the panel has no
|
||||
// other way to start a login mid-save.
|
||||
const startDisabled = startLogin.isPending || isActive || accountBindState === "saving";
|
||||
const startDisabled = !loginActive || startLogin.isPending || isActive || accountBindState === "saving";
|
||||
|
||||
// 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
|
||||
@@ -2736,7 +2744,7 @@ function DisplayedCodeLoginPanel({
|
||||
// active login for this owner.
|
||||
const resumeAttemptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
|
||||
if (!loginActive || resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
|
||||
resumeAttemptedRef.current = true;
|
||||
const active = activeSessionQuery.data;
|
||||
if (!active) return;
|
||||
@@ -2744,7 +2752,7 @@ function DisplayedCodeLoginPanel({
|
||||
setStartError(null);
|
||||
setLatchedPrompt(active.prompt ?? null);
|
||||
setSessionId(active.sessionId);
|
||||
}, [activeSessionQuery.isFetched, activeSessionQuery.data]);
|
||||
}, [loginActive, activeSessionQuery.isFetched, activeSessionQuery.data]);
|
||||
|
||||
// A resumed session's status poll found the session already gone: the read
|
||||
// that discovered it and the poll that tried to use it raced, and the
|
||||
@@ -2793,7 +2801,7 @@ function DisplayedCodeLoginPanel({
|
||||
const startLoginRef = useRef(startLogin.mutate);
|
||||
startLoginRef.current = startLogin.mutate;
|
||||
useEffect(() => {
|
||||
if (!autoStart || autoStartedRef.current) return;
|
||||
if (!loginActive || !autoStart || autoStartedRef.current) return;
|
||||
// A failed lookup is not proof that no session exists: only a successful
|
||||
// lookup is. Show the failure to the user instead of starting a second
|
||||
// login the server would reject against the per-owner cap.
|
||||
@@ -2808,6 +2816,7 @@ function DisplayedCodeLoginPanel({
|
||||
startLoginRef.current();
|
||||
}, [
|
||||
autoStart,
|
||||
loginActive,
|
||||
activeSessionQuery.isSuccess,
|
||||
activeSessionView.kind,
|
||||
activeSessionQuery.data,
|
||||
@@ -2821,10 +2830,10 @@ function DisplayedCodeLoginPanel({
|
||||
const onConnectedRef = useRef(onConnected);
|
||||
onConnectedRef.current = onConnected;
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated" || connectedRef.current) return;
|
||||
if (!loginActive || status !== "authenticated" || connectedRef.current) return;
|
||||
connectedRef.current = true;
|
||||
onConnectedRef.current?.(sessionId ?? undefined);
|
||||
}, [status]);
|
||||
}, [loginActive, status]);
|
||||
|
||||
// Drive the account-binding hand-off as a visible state machine, not a
|
||||
// fire-and-forget latch. The bind saves the agent, and the status poll
|
||||
@@ -2869,8 +2878,8 @@ function DisplayedCodeLoginPanel({
|
||||
const onPromptReadyRef = useRef(onPromptReady);
|
||||
onPromptReadyRef.current = onPromptReady;
|
||||
useEffect(() => {
|
||||
onPromptReadyRef.current?.(prompt?.url ?? null);
|
||||
}, [prompt]);
|
||||
onPromptReadyRef.current?.(loginActive ? prompt?.url ?? null : null);
|
||||
}, [loginActive, prompt]);
|
||||
|
||||
const failed = Boolean(isTerminal && status !== "authenticated") || statusQuery.isError;
|
||||
const retryingRef = useRef(false);
|
||||
@@ -3130,6 +3139,7 @@ const CLAUDE_LOGIN_TIMED_OUT_MESSAGE = "The login timed out. Start the login aga
|
||||
// and submits it. The panel clears the code right after submit. The panel treats
|
||||
// only the server `stored` state as success, and it never shows the OAuth token.
|
||||
function SubmittedBrowserCodeLoginPanel({
|
||||
active: loginActive = true,
|
||||
companyId,
|
||||
adapterType,
|
||||
environmentId,
|
||||
@@ -3147,6 +3157,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
onRetryReady,
|
||||
}: AdapterLoginPanelProps) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const cancellation = useAdapterLoginCancellation(loginActive, companyId, adapterType, true, sessionId);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
// The server delivers the authorization URL on the guarded prompt read only.
|
||||
@@ -3227,7 +3238,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const storedToken = storedTokenQuery.data ?? null;
|
||||
|
||||
const startLogin = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: () => cancellation.accept(() =>
|
||||
agentsApi.startClaudeSetupTokenLogin(companyId, {
|
||||
environmentId,
|
||||
aiConnection,
|
||||
@@ -3242,13 +3253,15 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
})),
|
||||
onSuccess: (session) => {
|
||||
if (!session || !cancellation.isActive()) return;
|
||||
resumedRef.current = false;
|
||||
resetLocalState();
|
||||
setSessionId(session.sessionId);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (!cancellation.isActive()) return;
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
@@ -3306,9 +3319,9 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
// with no local state. A 404 means no active session for the caller.
|
||||
const activeSessionQuery = useQuery({
|
||||
queryKey: ["claude-setup-token-active-session", companyId],
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ signal }) => {
|
||||
try {
|
||||
const active = await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId);
|
||||
const active = await cancellation.accept(() => agentsApi.getActiveClaudeSetupTokenLoginSession(companyId, { signal }));
|
||||
if (!active) return null;
|
||||
if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new Error("Another sign-in attempt is active. Finish or cancel it in its original account setup before starting this one.");
|
||||
return active;
|
||||
@@ -3324,6 +3337,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
// in flight — which the per-owner cap then rejects.
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
enabled: loginActive,
|
||||
});
|
||||
const activeSessionView = useQueryView(activeSessionQuery);
|
||||
|
||||
@@ -3337,11 +3351,11 @@ 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 && !retrying;
|
||||
loginActive && Boolean(sessionId) && !timedOut && !statusGone && !releasingResumedSession && !retrying;
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["claude-setup-token-status", companyId, sessionId],
|
||||
queryFn: () => agentsApi.getClaudeSetupTokenLoginStatus(companyId, sessionId!),
|
||||
queryFn: ({ signal }) => agentsApi.getClaudeSetupTokenLoginStatus(companyId, sessionId!, { signal }),
|
||||
enabled: pollingEnabled,
|
||||
// A status 404 is terminal. The server removes a cleaned-up session at once,
|
||||
// so a retry cannot recover it. The shared retry policy never retries a
|
||||
@@ -3394,7 +3408,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
// active login for this owner.
|
||||
const resumeAttemptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
|
||||
if (!loginActive || resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
|
||||
resumeAttemptedRef.current = true;
|
||||
const active = activeSessionQuery.data;
|
||||
if (!active) return;
|
||||
@@ -3405,7 +3419,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
setAuthorizationUrl(active.prompt.authorizationUrl);
|
||||
if (active.prompt.transportAdvisory) setTransportInsecure(true);
|
||||
}
|
||||
}, [activeSessionQuery.isFetched, activeSessionQuery.data]);
|
||||
}, [loginActive, activeSessionQuery.isFetched, activeSessionQuery.data]);
|
||||
|
||||
// Poll the guarded prompt route until it returns the authorization URL. The
|
||||
// route returns 404 until the URL is ready, so the panel treats a 404 as
|
||||
@@ -3413,9 +3427,9 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const promptQuery = useQuery({
|
||||
queryKey: ["claude-setup-token-prompt", companyId, sessionId],
|
||||
enabled: pollingEnabled && !authorizationUrl,
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ signal }) => {
|
||||
try {
|
||||
return await agentsApi.getClaudeSetupTokenLoginPrompt(companyId, sessionId!);
|
||||
return await agentsApi.getClaudeSetupTokenLoginPrompt(companyId, sessionId!, { signal });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
@@ -3437,6 +3451,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
mutationFn: (code: string) =>
|
||||
agentsApi.submitClaudeSetupTokenBrowserCode(companyId, sessionId!, code),
|
||||
onError: (error) => {
|
||||
if (!cancellation.isActive()) return;
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
@@ -3444,6 +3459,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const completeLogin = useMutation({
|
||||
mutationFn: () => agentsApi.completeClaudeSetupTokenLogin(companyId, sessionId!),
|
||||
onSuccess: (result) => {
|
||||
if (!cancellation.isActive()) return;
|
||||
// The server reached the `stored` state. Hold the non-secret claim and
|
||||
// report it to the parent. The response carries no token.
|
||||
setStoredSessionId(result.storedSessionId);
|
||||
@@ -3465,11 +3481,11 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const completeLoginRef = useRef(completeLogin.mutate);
|
||||
completeLoginRef.current = completeLogin.mutate;
|
||||
useEffect(() => {
|
||||
if (status === "authenticated" && !completionStartedRef.current) {
|
||||
if (loginActive && status === "authenticated" && !completionStartedRef.current) {
|
||||
completionStartedRef.current = true;
|
||||
completeLoginRef.current();
|
||||
}
|
||||
}, [status]);
|
||||
}, [loginActive, status]);
|
||||
|
||||
const isStored = storedSessionId !== null;
|
||||
const isFailure =
|
||||
@@ -3477,8 +3493,8 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
statusGone ||
|
||||
Boolean(status && CLAUDE_LOGIN_FAILURE_STATUSES.has(status));
|
||||
const isCompleting = status === "authenticated" && !isStored && !completionFailed;
|
||||
const isActive = Boolean(sessionId) && !isStored && !isFailure && !timedOut && !retrying;
|
||||
const startDisabled = startLogin.isPending || isActive;
|
||||
const isActive = loginActive && Boolean(sessionId) && !isStored && !isFailure && !timedOut && !retrying;
|
||||
const startDisabled = !loginActive || startLogin.isPending || isActive;
|
||||
|
||||
// Cap the active login at the server deadline. The timer arms when the login
|
||||
// becomes active and clears when the login leaves the active state (a terminal
|
||||
@@ -3562,7 +3578,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const startLoginRef = useRef(startLogin.mutate);
|
||||
startLoginRef.current = startLogin.mutate;
|
||||
useEffect(() => {
|
||||
if (!autoStart || autoStartedRef.current) return;
|
||||
if (!loginActive || !autoStart || autoStartedRef.current) return;
|
||||
// A failed lookup is not proof that no session exists: only a successful
|
||||
// lookup is. Show the failure to the user instead of starting a second
|
||||
// login the server would reject against the per-owner cap.
|
||||
@@ -3577,6 +3593,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
startLoginRef.current();
|
||||
}, [
|
||||
autoStart,
|
||||
loginActive,
|
||||
activeSessionQuery.isSuccess,
|
||||
activeSessionView.kind,
|
||||
activeSessionQuery.data,
|
||||
@@ -3630,10 +3647,10 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const onConnectedRef = useRef(onConnected);
|
||||
onConnectedRef.current = onConnected;
|
||||
useEffect(() => {
|
||||
if (!isStored || connectedRef.current) return;
|
||||
if (!loginActive || !isStored || connectedRef.current) return;
|
||||
connectedRef.current = true;
|
||||
onConnectedRef.current?.(sessionId ?? undefined);
|
||||
}, [isStored]);
|
||||
}, [loginActive, isStored]);
|
||||
|
||||
// The other end of `onCodeSubmitted`. Any of these after a submit means the
|
||||
// code is not going to become a stored login, and a caller still showing
|
||||
@@ -3651,8 +3668,8 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const onPromptReadyRef = useRef(onPromptReady);
|
||||
onPromptReadyRef.current = onPromptReady;
|
||||
useEffect(() => {
|
||||
onPromptReadyRef.current?.(authorizationUrl);
|
||||
}, [authorizationUrl]);
|
||||
onPromptReadyRef.current?.(loginActive ? authorizationUrl : null);
|
||||
}, [loginActive, authorizationUrl]);
|
||||
|
||||
const retryingRef = useRef(false);
|
||||
const retryLogin = useMutation({
|
||||
|
||||
@@ -3968,19 +3968,61 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("resumes the same claude_local session after Back, rather than starting a second", async () => {
|
||||
// This is the behaviour that makes the card's Cancel removable. Back only
|
||||
// hides the card — it deliberately does not release the session — so
|
||||
// coming back has to adopt the one already running. If it started a
|
||||
// fresh one instead, the removed Cancel would have been the only way out
|
||||
// of a login the customer could no longer reach, and the per-owner cap
|
||||
// would reject the second start.
|
||||
it.each(([
|
||||
["claude_local", "resume"], ["codex_local", "resume"],
|
||||
["claude_local", "start"], ["codex_local", "start"],
|
||||
["claude_local", "prompt"], ["codex_local", "prompt"],
|
||||
] as const).flatMap(([adapterType, stage]) => [false, true].map(afterExit => [adapterType, stage, afterExit] as const)))("keeps %s cancelled when its pending %s response arrives after Back (exit finished: %s)", async (adapterType, stage, afterExit) => {
|
||||
const claude = adapterType === "claude_local";
|
||||
const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession;
|
||||
const startApi = claude ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin;
|
||||
const promptApi = claude ? mockAgentsApi.getClaudeSetupTokenLoginPrompt : mockAgentsApi.getAdapterAuthLoginStatus;
|
||||
const cancelApi = claude ? mockAgentsApi.cancelClaudeSetupTokenLogin : mockAgentsApi.cancelAdapterAuthLogin;
|
||||
const session = {
|
||||
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?cancelled=true" }
|
||||
: { url: "https://auth.openai.com/codex/device", code: "CANCELLED-CODE" },
|
||||
};
|
||||
let finish!: (value: unknown) => void;
|
||||
const pending = new Promise(resolve => { finish = resolve; });
|
||||
const pendingApi = stage === "resume" ? activeApi : stage === "start" ? startApi : promptApi;
|
||||
pendingApi.mockReturnValue(pending as never);
|
||||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||||
const { root } = await openStep4({ adapterType });
|
||||
try {
|
||||
await pickSource(claude ? /Claude/ : /OpenAI/);
|
||||
expect(pendingApi).toHaveBeenCalled();
|
||||
await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click());
|
||||
if (afterExit) for (let i = 0; i < 12; i++) await flushReact();
|
||||
if (stage !== "start") {
|
||||
const signals = (pendingApi.mock.calls as unknown[][]).map(call => (call.at(-1) as { signal?: AbortSignal })?.signal).filter(signal => signal !== undefined);
|
||||
expect(signals.length).toBeGreaterThan(0);
|
||||
expect(signals.every(signal => signal.aborted)).toBe(true);
|
||||
}
|
||||
await act(async () => finish(stage === "prompt" && claude ? session.prompt : session));
|
||||
for (let i = 0; i < 12; i++) await flushReact();
|
||||
expect(document.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(0);
|
||||
expect(document.body.textContent).not.toContain("CANCELLED-CODE");
|
||||
expect(document.querySelector('input[aria-label="Authorization code"]')).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("Let's get started...");
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
if (claude) expect(cancelApi).toHaveBeenCalledWith("company-new", session.sessionId);
|
||||
else expect(cancelApi).toHaveBeenCalledWith("company-new", "codex_local", session.sessionId);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it("cancels claude_local on Back and starts a fresh sign-in when selected again", async () => {
|
||||
const session = {
|
||||
sessionId: "claude-session-1",
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
};
|
||||
let started = false;
|
||||
let finishCancellation!: () => void;
|
||||
mockAgentsApi.cancelClaudeSetupTokenLogin.mockImplementation(() => new Promise(resolve => { finishCancellation = () => { started = false; resolve({}); }; }));
|
||||
mockAgentsApi.startClaudeSetupTokenLogin.mockImplementation(async () => {
|
||||
started = true;
|
||||
return session;
|
||||
@@ -3993,8 +4035,6 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||||
|
||||
await pickSource(/Claude/);
|
||||
// The login is genuinely running: without this the assertion below holds
|
||||
// for the wrong reason.
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
|
||||
|
||||
const back = [...document.body.querySelectorAll("button")].find((b) =>
|
||||
@@ -4005,18 +4045,23 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
});
|
||||
for (let i = 0; i < 12; i++) await flushReact();
|
||||
|
||||
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalled();
|
||||
await pickSource(/Claude/);
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
|
||||
await act(async () => finishCancellation());
|
||||
for (let i = 0; i < 8; i++) await flushReact();
|
||||
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(2);
|
||||
expect(mockAgentsApi.getActiveClaudeSetupTokenLoginSession).toHaveBeenCalled();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("resumes the same codex_local session after Back, rather than starting a second", async () => {
|
||||
it("cancels codex_local on Back and starts a fresh sign-in when selected again", async () => {
|
||||
const session = { sessionId: "codex-session-1", status: "pending" };
|
||||
let started = false;
|
||||
let finishCancellation!: () => void;
|
||||
mockAgentsApi.cancelAdapterAuthLogin.mockImplementation(() => new Promise(resolve => { finishCancellation = () => { started = false; resolve({}); }; }));
|
||||
mockAgentsApi.startAdapterAuthLogin.mockImplementation(async () => {
|
||||
started = true;
|
||||
return session;
|
||||
@@ -4039,10 +4084,13 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
});
|
||||
for (let i = 0; i < 12; i++) await flushReact();
|
||||
|
||||
expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenCalled();
|
||||
await pickSource(/OpenAI/);
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
|
||||
await act(async () => finishCancellation());
|
||||
for (let i = 0; i < 8; i++) await flushReact();
|
||||
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(2);
|
||||
expect(mockAgentsApi.getActiveAdapterAuthLoginSession).toHaveBeenCalled();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
@@ -1149,25 +1149,35 @@ function OnboardingWizardInner({
|
||||
* panel unmounted and the resumed session unreachable from this step. A 404
|
||||
* means no active session for the caller.
|
||||
*/
|
||||
const dismissedLoginResumes = useRef(new Set<string>());
|
||||
const loginResumeKey = JSON.stringify([createdCompanyId, adapterType]);
|
||||
const activeLoginSessionQuery = useQuery({
|
||||
queryKey: createdCompanyId
|
||||
? queryKeys.agents.activeLoginSession(createdCompanyId, adapterType)
|
||||
: ["agents", "none", "active-login-session", adapterType],
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ signal }) => {
|
||||
try {
|
||||
return adapterCaps.login?.panelMode === "submitted_browser_code"
|
||||
? await agentsApi.getActiveClaudeSetupTokenLoginSession(createdCompanyId!)
|
||||
: await agentsApi.getActiveAdapterAuthLoginSession(createdCompanyId!, adapterType);
|
||||
const session = adapterCaps.login?.panelMode === "submitted_browser_code"
|
||||
? await agentsApi.getActiveClaudeSetupTokenLoginSession(createdCompanyId!, { signal })
|
||||
: await agentsApi.getActiveAdapterAuthLoginSession(createdCompanyId!, adapterType, { signal });
|
||||
if (session && dismissedLoginResumes.current.has(loginResumeKey)) {
|
||||
const cancel = adapterCaps.login?.panelMode === "submitted_browser_code"
|
||||
? agentsApi.cancelClaudeSetupTokenLogin(createdCompanyId!, session.sessionId)
|
||||
: agentsApi.cancelAdapterAuthLogin(createdCompanyId!, adapterType, session.sessionId);
|
||||
await cancel.catch(() => {});
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled:
|
||||
Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && canShowAdapterLogin,
|
||||
Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && canShowAdapterLogin && !dismissedLoginResumes.current.has(loginResumeKey),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!activeLoginSessionQuery.data || credentialMode === "api" || savedSubscription || savedKeys.storedLogin.data) return;
|
||||
if (dismissedLoginResumes.current.has(loginResumeKey) || !activeLoginSessionQuery.data || credentialMode === "api" || savedSubscription || savedKeys.storedLogin.data) return;
|
||||
// Re-derive the row's answer along with the sequence: a resumed session
|
||||
// implies a source was already picked, and the row stays a question
|
||||
// otherwise (see `sourcePicked` above).
|
||||
@@ -1554,21 +1564,10 @@ function OnboardingWizardInner({
|
||||
// it has been answered the button has nothing to do.
|
||||
{ label: "Next", icon: "arrow", disabled: true };
|
||||
|
||||
/**
|
||||
* Back, on the connect step, unwinds the sign-in before it leaves the step.
|
||||
*
|
||||
* This hides the card; it does not cancel the login. Unmounting the panel
|
||||
* does not release the server session — the session stays reachable for a
|
||||
* later resume, the same read that restores it after a reload — so backing
|
||||
* out and returning shows the sign-in still running, not a fresh one.
|
||||
*
|
||||
* Nothing releases it explicitly any more. The card carried a Cancel that
|
||||
* did, sitting beside an instruction and directly above this step's own
|
||||
* Back, and two ways out of one screen is one too many — the button went and
|
||||
* the release went with it. What is left is the server deadline, which is
|
||||
* the same thing that collects a session abandoned by closing the tab.
|
||||
*/
|
||||
function unwindConnectStep() {
|
||||
localLogin.cancel();
|
||||
dismissedLoginResumes.current.add(loginResumeKey);
|
||||
if (createdCompanyId) void queryClient.cancelQueries({ queryKey: queryKeys.agents.activeLoginSession(createdCompanyId, adapterType) });
|
||||
connectAttemptRef.current++;
|
||||
hiringAgentRef.current = null;
|
||||
setLoading(false);
|
||||
@@ -3242,11 +3241,6 @@ function OnboardingWizardInner({
|
||||
in the connect step's chrome. It owns the session; the
|
||||
step owns the sequence around it.
|
||||
|
||||
Unmounting it is not the cancel: the session stays
|
||||
reachable for a later resume, so Back and a source
|
||||
switch only hide the card, and nothing here releases
|
||||
the session early — see `unwindConnectStep`.
|
||||
|
||||
No "Use saved login" control: the hire step already
|
||||
applies a stored login on its own. */
|
||||
<AdapterLoginPanel
|
||||
@@ -3257,7 +3251,9 @@ function OnboardingWizardInner({
|
||||
chrome="onboarding"
|
||||
aiConnection={managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? { provider: managedProvider, method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true } : undefined}
|
||||
autoStart
|
||||
active={connectCardLive}
|
||||
onPromptReady={(url) => {
|
||||
if (!connectCardLive) return;
|
||||
setConnectAuthUrl(url);
|
||||
// The prompt arriving is what ends the waiting beat.
|
||||
if (url) setConnectPhase((p) => (p === "loading" ? "ready" : p));
|
||||
|
||||
@@ -22,7 +22,7 @@ beforeEach(() => {
|
||||
afterEach(() => { flushSync(() => root.unmount()); host.remove(); });
|
||||
function Harness({ name = "Account", provider = "openai", enabled = true }: { name?: string; provider?: "anthropic" | "openai"; enabled?: boolean }) {
|
||||
const login = useLocalAiLogin("company", { provider, method: "subscription", name, ownership: "personal", agentIds: [], allAgents: true }, enabled);
|
||||
return <><LocalProviderLoginInstructions adapterType={provider === "anthropic" ? "claude_local" : "codex_local"} login={login} /><button onClick={() => void login.connect()}>Connect</button></>;
|
||||
return <><LocalProviderLoginInstructions adapterType={provider === "anthropic" ? "claude_local" : "codex_local"} login={login} /><button onClick={() => void login.connect()}>Connect</button><button onClick={login.cancel}>Back</button></>;
|
||||
}
|
||||
it("checks once under StrictMode, preserves renaming and navigation, and cancels only on explicit retry", async () => {
|
||||
flushSync(() => root.render(<StrictMode><Harness name="First name" /></StrictMode>));
|
||||
@@ -106,3 +106,21 @@ it("hides a failed device prompt and lets the user restart sign-in", async () =>
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("NEW-CODE"));
|
||||
expect(host.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["start", "check"])("cancels a local subscription with a pending %s and ignores its late result", async stage => {
|
||||
let finish!: (result: unknown) => void;
|
||||
const pending = new Promise(resolve => { finish = resolve; });
|
||||
const held = stage === "start" ? api.startLocalLogin : api.checkLocalLogin;
|
||||
held.mockReturnValueOnce(pending);
|
||||
flushSync(() => root.render(<Harness />));
|
||||
await vi.waitFor(() => expect(held).toHaveBeenCalledTimes(1));
|
||||
flushSync(() => Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Back")!.click());
|
||||
finish(stage === "start" ? { sessionId: "attempt-1", expiresAt: "2099-01-01T00:00:00Z" }
|
||||
: { status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "CANCELLED-CODE" });
|
||||
await vi.waitFor(() => expect(api.cancelLocalLogin).toHaveBeenCalledWith("company", "attempt-1"));
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(host.textContent).not.toContain("CANCELLED-CODE");
|
||||
expect(api.checkLocalLogin).toHaveBeenCalledTimes(stage === "start" ? 0 : 1);
|
||||
expect(api.connectLocal).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -49,17 +49,17 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
|
||||
}
|
||||
const request = isolated ? current.current!.request : Promise.resolve(null);
|
||||
async function check() {
|
||||
if (checking || cancelled) return;
|
||||
if (checking || cancelled || (isolated && current.current?.request !== request)) return;
|
||||
checking = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const result = await request;
|
||||
if (cancelled) return;
|
||||
if (cancelled || (isolated && current.current?.request !== request)) return;
|
||||
setAttempt(result);
|
||||
const next = await aiConnectionsApi.checkLocalLogin(companyId!, {
|
||||
...latestIntent.current, ...(result ? { localSessionId: result.sessionId } : {}),
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (cancelled || (isolated && current.current?.request !== request)) return;
|
||||
setStatus(next.status);
|
||||
setAuthorizationUrl(next.authorizationUrl ?? null);
|
||||
setCode(next.code ?? null);
|
||||
@@ -69,7 +69,7 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
|
||||
// browser visit; sign-in never requires repeated Connect clicks.
|
||||
if (next.status === "sign_in_required") timer = setTimeout(() => void check(), 5000);
|
||||
} catch (cause) {
|
||||
if (!cancelled) setError(describeError(cause).body);
|
||||
if (!cancelled && (!isolated || current.current?.request === request)) setError(describeError(cause).body);
|
||||
} finally { checking = false; }
|
||||
}
|
||||
const onFocus = () => { if (!document.hidden) void check(); };
|
||||
@@ -94,6 +94,14 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
|
||||
status,
|
||||
preparing: active && !status && !error,
|
||||
error,
|
||||
cancel: () => {
|
||||
cancelCurrent();
|
||||
setAttempt(null);
|
||||
setStatus(null);
|
||||
setAuthorizationUrl(null);
|
||||
setCode(null);
|
||||
setError(null);
|
||||
},
|
||||
retry: () => { restartRequested.current = true; cancelCurrent(); setGeneration((value) => value + 1); },
|
||||
submitCode: async (browserCode: string) => {
|
||||
if (!companyId || !attempt) throw new Error("Start sign-in before submitting a code.");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi } from "../api/agents";
|
||||
|
||||
const pendingCancellations = new Map<string, Promise<unknown>>();
|
||||
|
||||
export function useAdapterLoginCancellation(active: boolean, companyId: string, adapterType: string, submittedCode: boolean, sessionId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const ownerKey = JSON.stringify([companyId, adapterType]);
|
||||
const scope = useRef({ active, generation: 0 });
|
||||
if (scope.current.active !== active) scope.current = { active, generation: scope.current.generation + 1 };
|
||||
const requests = useRef(new Set<Promise<unknown>>());
|
||||
const cancelled = useRef(new Map<string, Promise<unknown>>());
|
||||
const cancel = useCallback((id: string) => {
|
||||
const previous = cancelled.current.get(id);
|
||||
if (previous) return previous;
|
||||
const request = (submittedCode
|
||||
? agentsApi.cancelClaudeSetupTokenLogin(companyId, id)
|
||||
: agentsApi.cancelAdapterAuthLogin(companyId, adapterType, id)).catch(() => {});
|
||||
cancelled.current.set(id, request);
|
||||
return request;
|
||||
}, [companyId, adapterType, submittedCode]);
|
||||
|
||||
const accept = useCallback(<T extends { sessionId: string }>(request: () => Promise<T | null>): Promise<T | null> => {
|
||||
const generation = scope.current.generation;
|
||||
const result = (async () => {
|
||||
await pendingCancellations.get(ownerKey);
|
||||
if (!scope.current.active || scope.current.generation !== generation) return null;
|
||||
const session = await request();
|
||||
if (session && (!scope.current.active || scope.current.generation !== generation)) {
|
||||
await cancel(session.sessionId);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
})();
|
||||
requests.current.add(result);
|
||||
const forget = () => { requests.current.delete(result); };
|
||||
void result.then(forget, forget);
|
||||
return result;
|
||||
}, [cancel, ownerKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
const keys = submittedCode
|
||||
? [["claude-setup-token-active-session", companyId], ["claude-setup-token-status", companyId], ["claude-setup-token-prompt", companyId]]
|
||||
: [["adapter-login-active-session", companyId, adapterType], ["adapter-login-status", companyId, adapterType]];
|
||||
for (const queryKey of keys) void queryClient.cancelQueries({ queryKey });
|
||||
const cleanup = Promise.allSettled([...requests.current, ...(sessionId ? [cancel(sessionId)] : [])]);
|
||||
pendingCancellations.set(ownerKey, cleanup);
|
||||
void cleanup.then(() => {
|
||||
if (pendingCancellations.get(ownerKey) === cleanup) pendingCancellations.delete(ownerKey);
|
||||
});
|
||||
}, [active, companyId, adapterType, submittedCode, sessionId, queryClient, cancel, ownerKey]);
|
||||
|
||||
return { accept, isActive: () => scope.current.active };
|
||||
}
|
||||
Reference in new issue
Block a user