mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
fix(ai-connections): recover failed session lookup
Observe the shared session query in account details, expose read errors and retry session verification before account reads. Reuse personal default eligibility in account controls. Co-Authored-By: Paperclip <[email protected]>
This commit is contained in:
1 parent
8518f06433
commit
1767cce0ab
5 files changed
+81
-12
No files matched your search
@@ -57,6 +57,11 @@ const sessionQueryOptions = {
|
||||
queryFn: () => authApi.getSession(),
|
||||
} as const;
|
||||
|
||||
/** Observe the shared session read when a surface needs error and retry state. */
|
||||
export function useAccountSessionQuery() {
|
||||
return useQuery(sessionQueryOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in account, as the cache currently understands it.
|
||||
*
|
||||
@@ -79,7 +84,7 @@ export function useAccountIdentity(): {
|
||||
bootstrap: BoardBootstrap | undefined;
|
||||
updatedAt: number;
|
||||
} {
|
||||
const { data: session, isSuccess, isError, dataUpdatedAt } = useQuery(sessionQueryOptions);
|
||||
const { data: session, isSuccess, isError, dataUpdatedAt } = useAccountSessionQuery();
|
||||
const userId = session?.user.id ?? null;
|
||||
return {
|
||||
userId, settled: isSuccess, failed: isError, updatedAt: dataUpdatedAt,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
import { AI_PROVIDERS, type AiConnectionSummary } from "./model";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { AiDefaultCrown } from "./AiConnectionDefault";
|
||||
import { isOwnAiAccount } from "./useAiConnectionDefaults";
|
||||
|
||||
/** AI-only account controls; identity, access and navigation belong to AppDetail. */
|
||||
export function AiConnectionAccountControls({
|
||||
@@ -25,13 +26,13 @@ export function AiConnectionAccountControls({
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const [revokePending, setRevokePending] = useState(false);
|
||||
const [revokeError, setRevokeError] = useState<string>();
|
||||
const ownPersonal = account.ownership === "personal" && account.ownerUserId === currentUserId;
|
||||
const eligibleForDefault = isOwnAiAccount(account, currentUserId);
|
||||
const available = account.status === "connected";
|
||||
const activeDefault = account.isDefault && available;
|
||||
const providerName = AI_PROVIDERS[account.provider].name;
|
||||
return (
|
||||
<section className="space-y-4" aria-label="AI account settings">
|
||||
{ownPersonal && !account.routing && (
|
||||
{eligibleForDefault && (
|
||||
<div className={cn(
|
||||
"flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border p-3",
|
||||
activeDefault && "border-(--status-task-done)/30 bg-(--status-task-done)/5",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ManagedAiConnectionDetails } from "./ManagedAiConnectionDetails";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() }));
|
||||
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "user", settled: true }) }));
|
||||
vi.mock("@/api/auth", () => ({ authApi: { getSession: vi.fn(async () => ({ user: { id: "user" } })) } }));
|
||||
|
||||
const account: AiManagedConnectionSummary = {
|
||||
id: "connection", grantId: "grant", companyId: "company", provider: "anthropic", method: "subscription",
|
||||
|
||||
@@ -5,10 +5,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import type { AiManagedConnectionSummary, ToolConnection } from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { ManagedAiConnectionDetails } from "./ManagedAiConnectionDetails";
|
||||
|
||||
vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() }));
|
||||
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "user", settled: true }) }));
|
||||
vi.mock("@/api/auth", () => ({ authApi: { getSession: vi.fn() } }));
|
||||
vi.mock("./AiConnectionAccountControls", () => ({
|
||||
AiConnectionAccountControls: ({ account, grant }: { account: AiManagedConnectionSummary; grant: { id: string } }) => (
|
||||
<section aria-label="AI account settings" data-grant-id={grant.id}>{account.name}</section>
|
||||
@@ -39,6 +40,7 @@ beforeEach(() => {
|
||||
blocked = null;
|
||||
denied = false;
|
||||
signals = [];
|
||||
vi.mocked(authApi.getSession).mockReset().mockResolvedValue({ user: { id: "user" } } as Awaited<ReturnType<typeof authApi.getSession>>);
|
||||
fetchMock.mockReset().mockImplementation((url: string, init: RequestInit) => {
|
||||
if (url.endsWith("/active-runs")) return Promise.resolve(Response.json([]));
|
||||
const kind = url.endsWith("/grants") ? "grants" : "accounts";
|
||||
@@ -50,7 +52,7 @@ beforeEach(() => {
|
||||
if (denied) return Promise.resolve(Response.json({ error: "Access denied" }, { status: 403 }));
|
||||
return Promise.resolve(Response.json(kind === "grants" ? grants : accounts));
|
||||
});
|
||||
client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, gcTime: Infinity } } });
|
||||
client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, gcTime: Infinity, retry: false } } });
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
@@ -68,7 +70,8 @@ async function render() {
|
||||
await act(async () => {
|
||||
root.render(<QueryClientProvider client={client}><ManagedAiConnectionDetails connection={connection} /></QueryClientProvider>);
|
||||
});
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
|
||||
}
|
||||
|
||||
it("reuses the grants already loaded by the permissions page", async () => {
|
||||
@@ -78,6 +81,56 @@ it("reuses the grants already loaded by the permissions page", async () => {
|
||||
expect(fetchMock.mock.calls.some(([url]) => url.endsWith("/grants"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a failed session lookup and retries it before loading account data", async () => {
|
||||
vi.mocked(authApi.getSession).mockRejectedValueOnce(new Error("Session lookup failed"));
|
||||
await render();
|
||||
expect(host.querySelector('[role="alert"]')).not.toBeNull();
|
||||
expect(host.textContent).not.toContain("Loading AI account…");
|
||||
expect(host.querySelector("button")?.textContent).toBe("Retry");
|
||||
const accountReads = () => fetchMock.mock.calls.filter(([url]) => url.endsWith("/ai-connections"));
|
||||
expect(accountReads()).toHaveLength(0);
|
||||
expect(client.getQueryData(["ai-connections", "company", undefined, null])).toBeUndefined();
|
||||
await act(async () => {
|
||||
host.querySelector("button")!.click();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
|
||||
expect(authApi.getSession).toHaveBeenCalledTimes(2);
|
||||
expect(accountReads()).toHaveLength(1);
|
||||
expect(host.querySelector('[aria-label="AI account settings"]')?.textContent).toBe(account.name);
|
||||
expect(host.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("waits for session recovery before retrying an earlier account error", async () => {
|
||||
await render();
|
||||
denied = true;
|
||||
await act(async () => {
|
||||
await client.invalidateQueries({ queryKey: ["ai-connections"] });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
vi.mocked(authApi.getSession).mockRejectedValue(new Error("Session lookup failed"));
|
||||
await act(async () => {
|
||||
await client.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
const calls = fetchMock.mock.calls.length;
|
||||
let recover!: (session: Awaited<ReturnType<typeof authApi.getSession>>) => void;
|
||||
vi.mocked(authApi.getSession).mockImplementation(() => new Promise(resolve => { recover = resolve; }));
|
||||
await act(async () => {
|
||||
host.querySelector("button")!.click();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(calls);
|
||||
expect(host.querySelector('[aria-label="AI account settings"]')).toBeNull();
|
||||
denied = false;
|
||||
await act(async () => {
|
||||
recover({ user: { id: "user" } } as Awaited<ReturnType<typeof authApi.getSession>>);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
|
||||
expect(host.querySelector('[aria-label="AI account settings"]')?.textContent).toBe(account.name);
|
||||
});
|
||||
|
||||
it.each(["personal", "shared", "missing-first"] as const)("keeps an accessible %s grant visible without a saved personal default", async mode => {
|
||||
const personal = { ...account, ownerUserId: "user", grantId: "personal-grant", name: "Personal account", isDefault: false };
|
||||
const shared = { ...account, ownership: "shared" as const, grantId: "shared-grant", name: "Shared account", isDefault: false };
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { ToolConnection, AiConnectionMetadata } from "@paperclipai/shared";
|
||||
import { aiMethodLabel } from "./model";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { retryTransientOnly } from "@/lib/query-client";
|
||||
import { useAccountIdentity } from "@/api/companies-query";
|
||||
import { useAccountSessionQuery } from "@/api/companies-query";
|
||||
import { useAiConnectionDefaults } from "./useAiConnectionDefaults";
|
||||
import { AiDefaultError } from "./AiConnectionDefault";
|
||||
import { AI_PROVIDERS } from "./model";
|
||||
@@ -46,7 +46,11 @@ export function ManagedAiConnectionDetails({
|
||||
queryFn: () =>
|
||||
aiConnectionsApi.activeRuns(connection.companyId, connection.id),
|
||||
});
|
||||
const defaults = useAiConnectionDefaults(connection.companyId, useAccountIdentity());
|
||||
const session = useAccountSessionQuery();
|
||||
const defaults = useAiConnectionDefaults(connection.companyId, {
|
||||
userId: session.data?.user.id ?? null,
|
||||
settled: session.isSuccess,
|
||||
});
|
||||
const accounts = defaults.accounts;
|
||||
const grants = useQuery({
|
||||
queryKey: queryKeys.tools.connectionGrants(connection.id),
|
||||
@@ -54,6 +58,7 @@ export function ManagedAiConnectionDetails({
|
||||
retry: retryTransientOnly(1),
|
||||
});
|
||||
const runsView = useQueryView(runs);
|
||||
const sessionView = useQueryView(session);
|
||||
const accountsView = useQueryView(accounts);
|
||||
const grantsView = useQueryView(grants);
|
||||
const refresh = () => client.invalidateQueries();
|
||||
@@ -68,11 +73,12 @@ export function ManagedAiConnectionDetails({
|
||||
});
|
||||
const visibleGrants = new Set(grants.data?.grants.map(grant => grant.id));
|
||||
const candidates = defaults.data?.connections.filter(account => account.id === connection.id && account.companyId === connection.companyId && visibleGrants.has(account.grantId)) ?? [];
|
||||
// Routed personal accounts also need details; default eligibility excludes them.
|
||||
const ownPersonal = (account: (typeof candidates)[number]) => account.ownership === "personal" && account.ownerUserId === defaults.data?.currentUserId;
|
||||
const account = candidates.find(account => ownPersonal(account) && account.isDefault)
|
||||
?? candidates.find(ownPersonal) ?? candidates[0];
|
||||
const grant = grants.data?.grants.find((g) => g.id === account?.grantId);
|
||||
const loadError = [accountsView, grantsView].find((view) => view.kind === "error")?.error;
|
||||
const loadError = [sessionView, accountsView, grantsView].find((view) => view.kind === "error")?.error;
|
||||
const error = stop.error;
|
||||
const loadFailure = loadError ? (
|
||||
<div role="alert" className="space-y-2">
|
||||
@@ -80,8 +86,12 @@ export function ManagedAiConnectionDetails({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={accounts.isFetching || grants.isFetching}
|
||||
disabled={sessionView.isFetching || accounts.isFetching || grants.isFetching}
|
||||
onClick={() => {
|
||||
if (sessionView.kind === "error") {
|
||||
sessionView.retry();
|
||||
return;
|
||||
}
|
||||
if (accounts.error) void accounts.refetch();
|
||||
if (grants.error) void grants.refetch();
|
||||
}}
|
||||
@@ -94,7 +104,7 @@ export function ManagedAiConnectionDetails({
|
||||
if (!account || !grant)
|
||||
return (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{accounts.isPending || grants.isPending || accountsView.kind === "reconnecting" || grantsView.kind === "reconnecting"
|
||||
{accounts.isPending || grants.isPending || sessionView.kind === "reconnecting" || accountsView.kind === "reconnecting" || grantsView.kind === "reconnecting"
|
||||
? "Loading AI account…"
|
||||
: "This account is not available to you."}
|
||||
</p>
|
||||
|
||||
Reference in new issue
Block a user