Q196: a connector sign-in always ends connected, failed or timed out, and says which
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
56eac39b1b
commit
4ca61c5deb
7 files changed
+130
-9
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { SignInStatus } from "../pages/connectors/SignInStatus";
|
||||
import { useMemo } from "react";
|
||||
import type { AppSnapshot } from "../../../shared/domain";
|
||||
import { Button } from "../shared/ui";
|
||||
@@ -23,7 +24,7 @@ export function ConnectorsStep({
|
||||
actions: SnapshotActions;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
const { catalog, busySlug, connectError, connect, retry } = useConnectorCatalog(orgId, actions);
|
||||
const { catalog, busySlug, connectError, signIn, stopWaiting, checkAgain, connect, retry } = useConnectorCatalog(orgId, actions);
|
||||
const connectedSlugs = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
@@ -65,6 +66,7 @@ export function ConnectorsStep({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{signIn && <SignInStatus signIn={signIn} onStop={stopWaiting} onCheckAgain={checkAgain} />}
|
||||
{connectError && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -696,6 +696,12 @@ export const zh: Record<string, string> = {
|
||||
"Working language": "工作语言",
|
||||
"That did not work": "操作未成功",
|
||||
"Working…": "处理中…",
|
||||
"Waiting for the {name} sign-in in your browser (check {check} of {checks}).": "正在等待你在浏览器中完成 {name} 登录(第 {check}/{checks} 次检查)。",
|
||||
"Stop waiting": "停止等待",
|
||||
"{name} ({label}) is connected.": "{name}({label})已连接。",
|
||||
"{name} ({label}) is still not connected. If you finished signing in, check again; otherwise connect again.": "{name}({label})仍未连接。如果已经完成登录,请再次检查;否则请重新连接。",
|
||||
"Check again": "再次检查",
|
||||
"Connecting {name} ({label}) failed: {reason}": "连接 {name}({label})失败:{reason}",
|
||||
"Searching…": "正在搜索…",
|
||||
"Nothing matches “{words}”.": "没有与“{words}”匹配的结果。",
|
||||
"Show more": "显示更多",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SignInStatus } from "./connectors/SignInStatus";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "../shared/ui";
|
||||
import { t } from "../i18n/i18n";
|
||||
@@ -16,7 +17,7 @@ import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands";
|
||||
export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const employee = requireActiveEmployee(snapshot);
|
||||
const { catalog, busySlug, busyAction, connectError, connect, disconnect, grant, revoke, retry } = useConnectorCatalog(orgId, actions);
|
||||
const { catalog, busySlug, busyAction, connectError, signIn, stopWaiting, checkAgain, connect, disconnect, grant, revoke, retry } = useConnectorCatalog(orgId, actions);
|
||||
|
||||
const accounts = useMemo(
|
||||
() => snapshot.connectorAccounts.filter((account) => account.orgId === orgId),
|
||||
@@ -61,6 +62,8 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signIn && <SignInStatus signIn={signIn} onStop={stopWaiting} onCheckAgain={checkAgain} />}
|
||||
|
||||
<ConnectedList
|
||||
accounts={accounts}
|
||||
grants={grants}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Button } from "../../shared/ui";
|
||||
import { t } from "../../i18n/i18n";
|
||||
import type { SignIn } from "./useConnectorCatalog";
|
||||
|
||||
/** Says where a connector sign-in stands: waiting (with how many checks), connected, timed out, or failed. */
|
||||
export function SignInStatus({ signIn, onStop, onCheckAgain }: { signIn: SignIn; onStop: () => void; onCheckAgain: () => void }) {
|
||||
const name = signIn.slug;
|
||||
const failed = signIn.phase === "failed" || signIn.phase === "timed_out";
|
||||
return (
|
||||
<div role={failed ? "alert" : "status"} className="row" style={{ gap: 10, fontSize: 13, marginBottom: 14, color: failed ? "var(--red)" : "var(--text-2)" }}>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
{signIn.phase === "waiting" && t("Waiting for the {name} sign-in in your browser (check {check} of {checks}).", { name, check: signIn.check, checks: signIn.checks })}
|
||||
{signIn.phase === "connected" && t("{name} ({label}) is connected.", { name, label: signIn.label })}
|
||||
{signIn.phase === "timed_out" && t("{name} ({label}) is still not connected. If you finished signing in, check again; otherwise connect again.", { name, label: signIn.label })}
|
||||
{signIn.phase === "failed" && t("Connecting {name} ({label}) failed: {reason}", { name, label: signIn.label, reason: signIn.message })}
|
||||
</span>
|
||||
{signIn.phase === "waiting" && <Button size="small" onClick={onStop}>{t("Stop waiting")}</Button>}
|
||||
{signIn.phase === "timed_out" && <Button size="small" onClick={onCheckAgain}>{t("Check again")}</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** What one check of the account row says about the sign-in that created `newAccountId`: done, failed for good, or not yet. */
|
||||
export function judgeSignIn(
|
||||
account: { id: string; status: string; pendingAccountId: string | null } | undefined,
|
||||
newAccountId: string,
|
||||
): { outcome: "connected" } | { outcome: "failed"; message: string } | { outcome: "waiting" } {
|
||||
if (!account || account.pendingAccountId === newAccountId) return { outcome: "waiting" };
|
||||
// A reconnect that never took over leaves the earlier account in place; that is not this sign-in succeeding.
|
||||
if (account.id !== newAccountId) return { outcome: "failed", message: "The new sign-in did not complete; the earlier connection is kept." };
|
||||
if (account.status === "ACTIVE" && !account.pendingAccountId) return { outcome: "connected" };
|
||||
if (account.status === "FAILED" || account.status === "EXPIRED") return { outcome: "failed", message: `The account is ${account.status.toLowerCase()}.` };
|
||||
return { outcome: "waiting" };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { judgeSignIn } from "./sign-in";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ConnectorCatalogEntry } from "../../../../shared/commands";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
@@ -11,6 +12,14 @@ export type CatalogState =
|
||||
|
||||
type CatalogSlot = { orgId: string; state: CatalogState } | null;
|
||||
|
||||
/** Where a sign-in started from this window stands; it always ends connected, failed, or timed out. */
|
||||
export type SignIn = { slug: string; label: string; accountId: string } & (
|
||||
| { phase: "waiting"; check: number; checks: number }
|
||||
| { phase: "connected" }
|
||||
| { phase: "timed_out" }
|
||||
| { phase: "failed"; message: string }
|
||||
);
|
||||
|
||||
const CONNECT_POLL_INTERVAL_MS = 4000;
|
||||
const CONNECT_POLL_ATTEMPTS = 15;
|
||||
|
||||
@@ -24,6 +33,9 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
||||
busySlug: string | null;
|
||||
busyAction: "connect" | "disconnect" | null;
|
||||
connectError: string | null;
|
||||
signIn: SignIn | null;
|
||||
stopWaiting: () => void;
|
||||
checkAgain: () => void;
|
||||
connect: (toolkitSlug: string, label: string) => void;
|
||||
disconnect: (toolkitSlug: string, label: string) => void;
|
||||
grant: (agentId: string, toolkitSlug: string, accountLabel: string, employeeId: string) => void;
|
||||
@@ -33,7 +45,10 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
||||
const [catalogSlot, setCatalogSlot] = useState<CatalogSlot>(null);
|
||||
const [busy, setBusy] = useState<{ slug: string; action: "connect" | "disconnect" } | null>(null);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
const [signIn, setSignIn] = useState<SignIn | null>(null);
|
||||
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Each sign-in check run has its own number; a stopped or replaced run's late answers are dropped.
|
||||
const pollRun = useRef(0);
|
||||
const catalog: CatalogState = catalogSlot && catalogSlot.orgId === orgId ? catalogSlot.state : { phase: "loading" };
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -65,6 +80,7 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
||||
}, [orgId]);
|
||||
|
||||
useEffect(() => () => {
|
||||
pollRun.current += 1;
|
||||
if (pollTimer.current) clearTimeout(pollTimer.current);
|
||||
}, []);
|
||||
|
||||
@@ -79,39 +95,81 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
||||
[actions, orgId],
|
||||
);
|
||||
|
||||
const pollUntilActive = useCallback((toolkitSlug: string, label: string) => {
|
||||
// Checks the account until it is ACTIVE, failed, or the checks run out; the card stays busy until one of those.
|
||||
const pollUntilActive = useCallback((toolkitSlug: string, label: string, accountId: string) => {
|
||||
if (pollTimer.current) clearTimeout(pollTimer.current);
|
||||
pollRun.current += 1;
|
||||
const run = pollRun.current;
|
||||
const sign = { slug: toolkitSlug, label, accountId };
|
||||
const end = (outcome: SignIn) => {
|
||||
if (pollRun.current !== run) return;
|
||||
setSignIn(outcome);
|
||||
setBusy(null);
|
||||
};
|
||||
const poll = (attempt: number) => {
|
||||
if (attempt >= CONNECT_POLL_ATTEMPTS) return;
|
||||
if (pollRun.current !== run) return;
|
||||
if (attempt >= CONNECT_POLL_ATTEMPTS) {
|
||||
end({ ...sign, phase: "timed_out" });
|
||||
return;
|
||||
}
|
||||
setSignIn({ ...sign, phase: "waiting", check: attempt + 1, checks: CONNECT_POLL_ATTEMPTS });
|
||||
pollTimer.current = setTimeout(() => {
|
||||
void refreshAccounts().then((data) => {
|
||||
if (pollRun.current !== run) return;
|
||||
const account = data?.connectorAccounts.find(
|
||||
(item) => item.orgId === orgId && item.toolkitSlug === toolkitSlug && item.label === label,
|
||||
);
|
||||
if (account?.status === "ACTIVE" && !account.pendingAccountId) return;
|
||||
const judged = judgeSignIn(account, accountId);
|
||||
if (judged.outcome === "connected") return end({ ...sign, phase: "connected" });
|
||||
if (judged.outcome === "failed") return end({ ...sign, phase: "failed", message: judged.message });
|
||||
poll(attempt + 1);
|
||||
});
|
||||
}, (error: unknown) => end({ ...sign, phase: "failed", message: error instanceof Error ? error.message : String(error) }));
|
||||
}, CONNECT_POLL_INTERVAL_MS);
|
||||
};
|
||||
poll(0);
|
||||
}, [refreshAccounts, orgId]);
|
||||
|
||||
const stopWaiting = useCallback(() => {
|
||||
pollRun.current += 1;
|
||||
if (pollTimer.current) clearTimeout(pollTimer.current);
|
||||
setSignIn(null);
|
||||
setBusy(null);
|
||||
}, []);
|
||||
|
||||
const checkAgain = useCallback(() => {
|
||||
if (!signIn) return;
|
||||
setBusy({ slug: signIn.slug, action: "connect" });
|
||||
pollUntilActive(signIn.slug, signIn.label, signIn.accountId);
|
||||
}, [signIn, pollUntilActive]);
|
||||
|
||||
const connect = useCallback((toolkitSlug: string, label: string) => {
|
||||
pollRun.current += 1;
|
||||
const run = pollRun.current;
|
||||
if (pollTimer.current) clearTimeout(pollTimer.current);
|
||||
setBusy({ slug: toolkitSlug, action: "connect" });
|
||||
setConnectError(null);
|
||||
setSignIn(null);
|
||||
void window.agentCompany
|
||||
.connectComposioToolkit({ orgId, toolkitSlug, label })
|
||||
.then(async (result) => {
|
||||
// Unmounted, stopped or replaced while connecting: this answer starts nothing.
|
||||
if (pollRun.current !== run) return;
|
||||
if (!result.ok) {
|
||||
setConnectError(result.error.message);
|
||||
setBusy(null);
|
||||
return;
|
||||
}
|
||||
pollUntilActive(toolkitSlug, label.trim().toLowerCase());
|
||||
// Opening the browser is not being connected: the card stays busy while the sign-in is checked.
|
||||
pollUntilActive(toolkitSlug, label.trim().toLowerCase(), result.data.accountId);
|
||||
if (result.data.redirectUrl) {
|
||||
const opened = await window.agentCompany.openExternalUrl(result.data.redirectUrl);
|
||||
if (!opened.ok) setConnectError(opened.error.message);
|
||||
}
|
||||
})
|
||||
.finally(() => setBusy(null));
|
||||
}, (error: unknown) => {
|
||||
if (pollRun.current !== run) return;
|
||||
setConnectError(error instanceof Error ? error.message : String(error));
|
||||
setBusy(null);
|
||||
});
|
||||
}, [orgId, pollUntilActive]);
|
||||
|
||||
const disconnect = useCallback((toolkitSlug: string, label: string) => {
|
||||
@@ -139,6 +197,9 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
||||
busySlug: busy?.slug ?? null,
|
||||
busyAction: busy?.action ?? null,
|
||||
connectError,
|
||||
signIn,
|
||||
stopWaiting,
|
||||
checkAgain,
|
||||
connect,
|
||||
disconnect,
|
||||
grant,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { judgeSignIn } from "../../src/renderer/src/pages/connectors/sign-in";
|
||||
|
||||
describe("connector sign-in", () => {
|
||||
test("ends connected or failed only on what the new account says; anything else keeps waiting", () => {
|
||||
const row = (id: string, status: string, pendingAccountId: string | null = null) => ({ id, status, pendingAccountId });
|
||||
expect(judgeSignIn(row("ca_new", "ACTIVE"), "ca_new")).toEqual({ outcome: "connected" });
|
||||
expect(judgeSignIn(row("ca_new", "FAILED"), "ca_new")).toEqual({ outcome: "failed", message: "The account is failed." });
|
||||
expect(judgeSignIn(row("ca_new", "EXPIRED"), "ca_new")).toEqual({ outcome: "failed", message: "The account is expired." });
|
||||
expect(judgeSignIn(row("ca_new", "INITIATED"), "ca_new")).toEqual({ outcome: "waiting" });
|
||||
expect(judgeSignIn(undefined, "ca_new")).toEqual({ outcome: "waiting" });
|
||||
// A reconnect waits while the new account is pending, and fails if it never takes over from the old one.
|
||||
expect(judgeSignIn(row("ca_old", "ACTIVE", "ca_new"), "ca_new")).toEqual({ outcome: "waiting" });
|
||||
expect(judgeSignIn(row("ca_old", "ACTIVE"), "ca_new")).toEqual({ outcome: "failed", message: "The new sign-in did not complete; the earlier connection is kept." });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user