diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 0372f939330..bef4ac78c97 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -73,6 +73,26 @@ Environment selection does not change agent execution settings. API keys are validated against fixed provider endpoints; redirects and caller-supplied validation URLs are rejected. +In **Connectors**, expand a provider's accounts. A crown beside an account's name +marks your saved default for that provider in this company. Hover, focus, or tap +the crown for its label. In another personal account's menu, choose **Set as +default for OpenAI**, **Set as default for Claude**, or the matching provider. +Subscription and API-key accounts share one provider default. The current default +has a disabled **Default for {provider}** menu item. The list API's `canSetDefault` +flag projects human self-service configure authority once per list request. +Agent list responses omit this optional UI field and retain their existing +write authorization. Shared accounts, other owners, +routed profiles, and callers without permission do not receive this action. + +While saving, the previous crown stays visible and other default writes for that +provider are disabled. After success, scoped account data and agent selectors +refresh from the server. Failed writes retain the previous selection and offer +an explicit Retry for the same grant, with eligibility checked again. Returning +to the tab refreshes defaults changed elsewhere. An unavailable selected account +keeps its crown and **Default unavailable** state until it is reconnected or +explicitly replaced. Changing the preference grants no access and affects the +next normal run admission; pinned selections and active runs retain their accounts. + `runtimeConfig.aiConnection` contains `provider`, `mode`, and `method`. For responsible-user selections, `method` is a legacy wire hint retained for rolling upgrades; the resolver uses the selected account’s actual method: - `responsible_user`: resolve the run's responsible user's personal provider default, using that account's subscription or API key. The `method` hint does not restrict the responsible user's account. diff --git a/packages/shared/src/ai-connections.ts b/packages/shared/src/ai-connections.ts index fb784919866..d16164c6306 100644 --- a/packages/shared/src/ai-connections.ts +++ b/packages/shared/src/ai-connections.ts @@ -186,6 +186,7 @@ export interface AiManagedConnectionSummary { ownerUserId?: string; ownerName?: string; isDefault: boolean; + canSetDefault?: boolean; status: "connected" | "needs_attention" | "expired" | "revoked"; unavailableReason?: string; usageProbeSupported?: boolean; diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 07b9616c0a8..c18d8c7941c 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -2455,6 +2455,15 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); }); const personal = await service.select({ ...input, userId: "alice" }); const base = `/api/companies/${companyId}/ai-connections`; + const ownerList = await request(app).get(base); + expect(ownerList.status).toBe(200); + expect(ownerList.body.connections.find((account: { grantId: string }) => account.grantId === personal.grant.id)?.canSetDefault).toBe(true); + const viewerList = await request(app).get(base).set("x-test-role", "viewer"); + expect(viewerList.status).toBe(200); + expect(viewerList.body.connections.every((account: { canSetDefault: boolean }) => account.canSetDefault === false)).toBe(true); + const otherUserList = await request(app).get(base).set("x-test-user", "bob"); + expect(otherUserList.status).toBe(200); + expect(otherUserList.body.connections.some((account: { grantId: string; canSetDefault: boolean }) => account.grantId === personal.grant.id && account.canSetDefault)).toBe(false); expect((await request(app).get(`/api/companies/${otherCompanyId}/ai-connections`)).status).toBe(403); expect((await request(app).put(`${base}/default`).set("x-test-user", "bob").send({ grantId: personal.grant.id })).status).toBe(403); expect((await request(app).put(`${base}/default`).set("x-test-role", "viewer").send({ grantId: personal.grant.id })).status).toBe(403); @@ -2467,6 +2476,60 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" expect(network).not.toHaveBeenCalled(); } finally { network.mockRestore(); } }); + it("keeps personal-default list queries constant and other reads responsive with a blocked membership read", async () => { + const testCompany = randomUUID(), owner = "default-list-cost"; + await db.insert(companies).values({ id: testCompany, name: "Default list cost", issuePrefix: "DLC" }); + await db.insert(companyMemberships).values({ companyId: testCompany, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); + const first = await service.save(testCompany, owner, { provider: "openai", method: "api_key", ownership: "personal", name: "Default cost", agentIds: [], allAgents: false }, "fixture-cost"); + const app = express(); + app.use((req, _res, next) => { + req.actor = { type: "board", source: "session", userId: owner, companyIds: [testCompany], memberships: [{ companyId: testCompany, membershipRole: "member", status: "active" }] }; + next(); + }); + app.get("/probe", async (_req, res) => { await db.execute(sql`select 1`); res.sendStatus(200); }); + app.use("/api", aiConnectionRoutes(db)); + const url = `/api/companies/${testCompany}/ai-connections`; + const selects = vi.spyOn(db, "select"); + let smallQueries: number, largeQueries: number; + try { + expect((await request(app).get(url)).status).toBe(200); + smallQueries = selects.mock.calls.length; + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, first.connectionId)); + const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, first.grantId)); + const ids = Array.from({ length: 199 }, () => randomUUID()); + await db.insert(toolConnections).values(ids.map(id => ({ ...connection, id, uid: id, name: `Cost ${id}` }))); + await db.insert(connectionGrants).values(ids.map(connectionId => ({ ...grant, id: randomUUID(), connectionId }))); + selects.mockClear(); + const large = await request(app).get(url); + largeQueries = selects.mock.calls.length; + expect(large.status).toBe(200); + expect(large.body.connections).toHaveLength(200); + expect(large.body.connections.every((account: { canSetDefault: boolean }) => account.canSetDefault)).toBe(true); + expect(largeQueries).toBe(smallQueries); + expect(largeQueries).toBeLessThan(30); + } finally { selects.mockRestore(); } + + const held = deferredSignal(), release = deferredSignal(); + const holder = db.transaction(async tx => { + await tx.execute(sql`lock table company_memberships in access exclusive mode`); + held.resolve(); await release.promise; + }); + await held.promise; + const pending = request(app).get(url).then(response => response); + try { + let blocked = false; + const deadline = Date.now() + 5_000; + while (!blocked && Date.now() < deadline) { + const rows = await db.execute(sql`select pid from pg_stat_activity where wait_event_type = 'Lock' and query like '%company_memberships%'`); + blocked = rows.length > 0; + if (!blocked) await new Promise(resolve => setTimeout(resolve, 10)); + } + expect(blocked).toBe(true); + expect((await request(app).get("/probe").timeout(2_000)).status).toBe(200); + } finally { release.resolve(); await holder; } + expect((await pending).status).toBe(200); + }); + it.each(["anthropic", "openai"] as const)("reports a missing %s browser process after a server restart", async provider => { const [environment] = await db.select().from(environments).where(eq(environments.driver, "local")).limit(1); const id = randomUUID(); diff --git a/server/src/routes/ai-connections.ts b/server/src/routes/ai-connections.ts index 69a984263a0..23203f7ad9f 100644 --- a/server/src/routes/ai-connections.ts +++ b/server/src/routes/ai-connections.ts @@ -227,15 +227,28 @@ export function aiConnectionRoutes(db: Db, options: Parameters ({ connectionId: account.id })); + const viewer = (req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships) + ?.some(m => m.companyId === companyId && m.membershipRole === "viewer"); + const management = connectionManagementService(db); + const context = connectionRequestContext(req); + const personalDefaultCapability = req.actor.type === "board" ? !viewer && (await management.capability(req.actor, "connections:configure", { + type: "connection_management", companyId, purpose: "ai", selfServiceUserId: currentUserId, + operation: "PUT /companies/:companyId/ai-connections/default", readOnly: false, + }, { mutation: false, readContext: context })).allowed : undefined; + const connections = accounts.map(account => ({ + ...account, + ...(personalDefaultCapability === undefined ? {} : { + canSetDefault: personalDefaultCapability && account.ownership === "personal" && account.ownerUserId === currentUserId && !account.routing, + }), + })); res.json({ currentUserId, pools: await filterConnectionManagementRows(db, req, "connections:read", await pools.selectable(companyId, currentUserId), pool => ({ connectionId: pool.id })), canManageConnections: await canManageAiConnections(db, req, companyId), - connections: await filterConnectionManagementRows(db, req, "connections:read", await service.list( - companyId, - currentUserId, - agentId as string | undefined, - ), account => ({ connectionId: account.id })), + connections, } satisfies AiConnectionList); }); router.get( diff --git a/tests/ai-connections-app/app.spec.ts b/tests/ai-connections-app/app.spec.ts index 78cd5b4c48f..0acea3c9752 100644 --- a/tests/ai-connections-app/app.spec.ts +++ b/tests/ai-connections-app/app.spec.ts @@ -17,11 +17,11 @@ test("existing Connections lists AI providers and keeps account management compa await expect(page.getByRole("button", { name: new RegExp(`^(Add account|Connect) ${provider}$`) })).toBeVisible(); } const { connections } = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json(); - const account = connections.find((entry: { ownership: string }) => entry.ownership === "personal"); + const account = connections.find((entry: { ownership: string; isDefault: boolean }) => entry.ownership === "personal" && entry.isDefault); expect(account, "The isolated drive should include an imported personal account").toBeTruthy(); await page.goto(`/${prefix}/apps/${account.id}/permissions`); await expect(page.getByRole("heading", { name: account.name, exact: true })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Personal default", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: /^Default for / })).toBeVisible(); await expect(page.getByRole("heading", { name: "Agent usage", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Back to Connectors", exact: true })).toHaveCount(0); await expect(page.getByRole("heading", { name: "Which humans can use this credential?" })).toBeVisible(); @@ -91,6 +91,104 @@ test("connection setup waits for provider details before enabling Continue", asy await expect(page).toHaveURL(/stage=setup/); }); +test("personal provider defaults move the crown only after saving and survive refresh", async ({ page, request }, testInfo) => { + const list = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json(); + const own = list.connections.filter((account: { provider: string; ownership: string; ownerUserId: string; canSetDefault: boolean; status: string }) => + account.provider === "openai" && account.ownership === "personal" && account.ownerUserId === list.currentUserId && account.canSetDefault && account.status === "connected"); + const previous = own.find((account: { isDefault: boolean }) => account.isDefault); + const target = own.find((account: { isDefault: boolean }) => !account.isDefault); + expect(previous, "Seed a selected personal OpenAI account").toBeTruthy(); + expect(target, "Seed a second personal OpenAI account").toBeTruthy(); + const others = list.connections.filter((account: { provider: string; isDefault: boolean }) => account.provider !== "openai" && account.isDefault).map((account: { grantId: string }) => account.grantId).sort(); + await page.goto(`/${prefix}/apps?source=installed`); + const announcement = page.getByRole("button", { name: "Dismiss announcement" }); + if (await announcement.isVisible()) await announcement.click(); + const provider = page.locator('[data-app-slug="openai"]'); + const expand = async () => { + const trigger = provider.getByRole("button", { name: /^\d+ connections$/ }); + if (await trigger.getAttribute("data-state") === "closed") await trigger.click(); + }; + await expect(provider).toBeVisible(); + await expand(); + const crown = provider.getByRole("button", { name: "Your default for OpenAI" }); + await expect(crown.locator("..")).toContainText(previous.name); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let writes = 0; + await page.route(`**/api/companies/${companyId}/ai-connections/default`, async route => { + writes++; + expect(route.request().postDataJSON()).toEqual({ grantId: target.grantId }); + await held; + await route.continue(); + }); + const manage = provider.getByRole("button", { name: `Manage ${target.name} connection` }); + await manage.click(); + await page.screenshot({ path: testInfo.outputPath("default-menu.png") }); + await page.getByRole("menuitem", { name: "Set as default for OpenAI", exact: true }).click(); + await expect.poll(() => writes).toBe(1); + await expect(crown.locator("..")).toContainText(previous.name); + await manage.click(); + await expect(page.getByRole("menuitem", { name: "Set as default for OpenAI", exact: true })).toHaveAttribute("aria-disabled", "true"); + await page.keyboard.press("Escape"); + release(); + await expect(crown.locator("..")).toContainText(target.name); + await expect(manage).toBeFocused(); + await page.screenshot({ path: testInfo.outputPath("default-saved.png"), fullPage: true }); + await page.unroute(`**/api/companies/${companyId}/ai-connections/default`); + const saved = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json(); + expect(saved.connections.filter((account: { provider: string; isDefault: boolean }) => account.provider === "openai" && account.isDefault).map((account: { grantId: string }) => account.grantId)).toEqual([target.grantId]); + expect(saved.connections.filter((account: { provider: string; isDefault: boolean }) => account.provider !== "openai" && account.isDefault).map((account: { grantId: string }) => account.grantId).sort()).toEqual(others); + await page.reload(); + await expand(); + await expect(crown.locator("..")).toContainText(target.name); + await crown.scrollIntoViewIfNeeded(); + await crown.focus(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Shift+Tab"); + await expect(page.getByRole("tooltip")).toHaveText("Your default for OpenAI"); + + await page.route(`**/api/companies/${companyId}/ai-connections/default`, async route => { + await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "Disposable QA save failure" }) }); + }, { times: 1 }); + await provider.getByRole("button", { name: `Manage ${previous.name} connection` }).click(); + await page.getByRole("menuitem", { name: "Set as default for OpenAI", exact: true }).click(); + await expect(page.getByRole("alert").filter({ hasText: "Couldn’t change your OpenAI default" })).toBeVisible(); + await expect(crown.locator("..")).toContainText(target.name); + await page.getByRole("alert").filter({ hasText: "Couldn’t change your OpenAI default" }).getByRole("button", { name: "Retry", exact: true }).click(); + await expect(crown.locator("..")).toContainText(previous.name); + const shared = saved.connections.find((account: { provider: string; ownership: string }) => account.provider === "openai" && account.ownership === "shared"); + if (shared) { + await provider.getByRole("button", { name: `Manage ${shared.name} connection` }).click(); + await expect(page.getByRole("menuitem", { name: /default for OpenAI/ })).toHaveCount(0); + await page.keyboard.press("Escape"); + } +}); + +test("personal provider defaults expose crown meaning on touch in both themes at 390px", async ({ browser, request }, testInfo) => { + const list = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json(); + const selected = list.connections.find((account: { provider: string; isDefault: boolean }) => account.provider === "openai" && account.isDefault); + expect(selected).toBeTruthy(); + for (const colorScheme of ["light", "dark"] as const) { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, colorScheme, reducedMotion: "reduce" }); + const page = await context.newPage(); + await page.goto(`${testInfo.project.use.baseURL}/${prefix}/apps?source=installed`); + const announcement = page.getByRole("button", { name: "Dismiss announcement" }); + if (await announcement.isVisible()) await announcement.click(); + const provider = page.locator('[data-app-slug="openai"]'); + await provider.getByRole("button", { name: /^\d+ connections$/ }).tap(); + const crown = provider.getByRole("button", { name: "Your default for OpenAI" }); + await crown.scrollIntoViewIfNeeded(); + await crown.tap(); + await expect(page.getByRole("tooltip")).toHaveText("Your default for OpenAI"); + await page.keyboard.press("Escape"); + await provider.getByRole("button", { name: `Manage ${selected.name} connection` }).tap(); + await expect(page.getByRole("menuitem", { name: "Default for OpenAI", exact: true })).toHaveAttribute("aria-disabled", "true"); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath(`default-mobile-${colorScheme}.png`) }); + await context.close(); + } +}); + test("new OpenRouter agents use the visible binding and provider model catalog", async ({ page }) => { let tested: { aiConnection?: unknown; adapterConfig?: { model?: string } } | undefined; await page.route(`**/api/companies/${companyId}/adapters/opencode_local/models*`, async route => { diff --git a/ui/src/api/companies-query.ts b/ui/src/api/companies-query.ts index c072208bec2..a922da06c46 100644 --- a/ui/src/api/companies-query.ts +++ b/ui/src/api/companies-query.ts @@ -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, diff --git a/ui/src/components/ai-connections/AiConnectionAccountControls.tsx b/ui/src/components/ai-connections/AiConnectionAccountControls.tsx index f7d4c42291c..27e4c17a594 100644 --- a/ui/src/components/ai-connections/AiConnectionAccountControls.tsx +++ b/ui/src/components/ai-connections/AiConnectionAccountControls.tsx @@ -1,20 +1,23 @@ import { useState, type ReactNode } from "react"; -import { RefreshCw, Star, Unplug } from "lucide-react"; +import { RefreshCw, Unplug } from "lucide-react"; import type { ConnectionGrant } from "@paperclipai/shared"; import { RevokeGrantDialog } from "@/pages/apps/app-detail/IdentitiesSection"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import type { AiConnectionSummary } from "./model"; +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({ - account, grant, currentUserId, readOnly, onMakeDefault, onReconnect, onRevoke, revocationDetails, + account, grant, currentUserId, readOnly, defaultPending, onMakeDefault, onReconnect, onRevoke, revocationDetails, }: { account: AiConnectionSummary; grant: ConnectionGrant; currentUserId: string; readOnly?: boolean; + defaultPending?: boolean; onMakeDefault: () => void; onReconnect: () => void; onRevoke: () => void | Promise; @@ -23,22 +26,23 @@ export function AiConnectionAccountControls({ const [revoking, setRevoking] = useState(false); const [revokePending, setRevokePending] = useState(false); const [revokeError, setRevokeError] = useState(); - 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 (
- {ownPersonal && !account.routing && ( + {eligibleForDefault && (
- -

{account.isDefault && !available ? "Default unavailable" : "Personal default"}

+ {account.isDefault && } +

{account.isDefault ? available ? `Default for ${providerName}` : "Default unavailable" : `Your ${providerName} default`}

- {!account.isDefault && (!readOnly ? ( - + {!account.isDefault && (!readOnly && account.canSetDefault ? ( + ) : Not default)}
)} diff --git a/ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx b/ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx index b4ea87446f9..72f4ba38d9f 100644 --- a/ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx +++ b/ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx @@ -6,8 +6,10 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { AiConnectionAccountIdentity, AiManagedConnectionSummary, ConnectionGrant, ToolConnection } from "@paperclipai/shared"; import { AiConnectionAccountDetails } from "./AiConnectionAccountDetails"; import { ManagedAiConnectionDetails } from "./ManagedAiConnectionDetails"; +import { TooltipProvider } from "@/components/ui/tooltip"; vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() })); +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", @@ -59,9 +61,9 @@ afterEach(async () => { async function render(value = account, selectedGrant = grant, completePanel = false) { await act(async () => { - root.render({completePanel + root.render({completePanel ? - : }); + : }); }); await act(async () => { await vi.advanceTimersByTimeAsync(10); }); await act(async () => { await vi.advanceTimersByTimeAsync(10); }); @@ -79,7 +81,7 @@ it("shows the provider email and organization separately from the Paperclip owne it("explains profile permission denial while preserving connected account controls", async () => { observation = { ...known, status: "unavailable", accountLabel: null, organizationLabel: null, errorCode: "permission_denied", message: "Anthropic did not permit this connection to read the account email." }; await render(account, grant, true); - expect(host.textContent).toContain("Personal default"); + expect(host.textContent).toContain("Default for Claude"); expect(host.textContent).toContain("Reconnect"); expect(host.textContent).toContain("did not permit"); expect(host.textContent).not.toContain("avery@example.test"); @@ -91,12 +93,12 @@ it("explains profile permission denial while preserving connected account contro it("keeps maintenance controls visible during a stalled lookup and recovers only the identity request", async () => { blocked = true; await render(account, grant, true); - expect(host.textContent).toContain("Personal default"); + expect(host.textContent).toContain("Default for Claude"); expect(host.textContent).toContain("Reconnect"); expect(host.textContent).toContain("Checking account details…"); await act(async () => { await vi.advanceTimersByTimeAsync(20_001); }); expect(signals[0].aborted).toBe(true); - expect(host.textContent).toContain("Personal default"); + expect(host.textContent).toContain("Default for Claude"); expect(host.textContent).not.toContain("Checking account details…"); const ordinaryCalls = fetchMock.mock.calls.length - identityCalls().length; blocked = false; diff --git a/ui/src/components/ai-connections/AiConnectionDefault.tsx b/ui/src/components/ai-connections/AiConnectionDefault.tsx new file mode 100644 index 00000000000..b5ee6fe8d93 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionDefault.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import { Crown, Loader2 } from "lucide-react"; +import type { AiManagedConnectionSummary } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { AI_PROVIDERS } from "./model"; + +export function AiDefaultCrown({ account }: { account: Pick }) { + const [open, setOpen] = useState(false); + const label = `Your default for ${AI_PROVIDERS[account.provider].name}`; + return + + + + {label} + ; +} + +export function AiDefaultMenuItem({ account, pending, onSelect }: { + account: AiManagedConnectionSummary; pending: boolean; onSelect: () => void; +}) { + const provider = AI_PROVIDERS[account.provider].name; + const reason = account.unavailableReason ?? (account.status !== "connected" ? "Reconnect this account before making it your default." : null); + return + {pending ? ; +} + +export function AiDefaultError({ provider, pending, onRetry }: { + provider: string; pending: boolean; onRetry: () => void; +}) { + return
+

Couldn’t change your {provider} default.

+ +
; +} diff --git a/ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx b/ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx index 815fd8d871b..d536f9966fc 100644 --- a/ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx +++ b/ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx @@ -5,12 +5,14 @@ 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/auth", () => ({ authApi: { getSession: vi.fn() } })); vi.mock("./AiConnectionAccountControls", () => ({ - AiConnectionAccountControls: ({ account }: { account: AiManagedConnectionSummary }) => ( -
{account.name}
+ AiConnectionAccountControls: ({ account, grant }: { account: AiManagedConnectionSummary; grant: { id: string } }) => ( +
{account.name}
), })); vi.mock("./AiConnectionUsagePanel", () => ({ AiConnectionUsagePanel: () => null })); @@ -38,6 +40,7 @@ beforeEach(() => { blocked = null; denied = false; signals = []; + vi.mocked(authApi.getSession).mockReset().mockResolvedValue({ user: { id: "user" } } as Awaited>); fetchMock.mockReset().mockImplementation((url: string, init: RequestInit) => { if (url.endsWith("/active-runs")) return Promise.resolve(Response.json([])); const kind = url.endsWith("/grants") ? "grants" : "accounts"; @@ -49,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); @@ -67,7 +70,8 @@ async function render() { await act(async () => { root.render(); }); - 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 () => { @@ -77,6 +81,73 @@ 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>) => 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>); + 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 }; + const other = { ...shared, grantId: "other-shared-grant", name: "Other shared account" }; + const choices = mode === "personal" ? [shared, personal] : [other, shared]; + const available = mode === "missing-first" ? [shared] : choices; + fetchMock.mockImplementation((url: string) => Promise.resolve(Response.json(url.endsWith("/active-runs") ? [] + : url.endsWith("/grants") ? { ...grants, grants: available.map(choice => ({ id: choice.grantId })) } + : { ...accounts, connections: choices }))); + await render(); + const expected = mode === "personal" ? personal : mode === "missing-first" ? shared : other; + const controls = host.querySelector('[aria-label="AI account settings"]'); + expect(controls?.textContent).toBe(expected.name); + expect(controls?.getAttribute("data-grant-id")).toBe(expected.grantId); + expect(host.textContent).not.toContain("not available to you"); +}); + it.each(["accounts", "grants"] as const)("bounds a stuck %s read, retries once, and recovers from the retry button", async kind => { blocked = kind; await render(); diff --git a/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx index 1ee75cdf5f7..1281d255fbf 100644 --- a/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx +++ b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx @@ -13,6 +13,10 @@ import type { ToolConnection, AiConnectionMetadata } from "@paperclipai/shared"; import { aiMethodLabel } from "./model"; import { queryKeys } from "@/lib/queryKeys"; import { retryTransientOnly } from "@/lib/query-client"; +import { useAccountSessionQuery } from "@/api/companies-query"; +import { useAiConnectionDefaults } from "./useAiConnectionDefaults"; +import { AiDefaultError } from "./AiConnectionDefault"; +import { AI_PROVIDERS } from "./model"; export function ManagedAiConnectionRow({ connection, @@ -42,25 +46,22 @@ export function ManagedAiConnectionDetails({ queryFn: () => aiConnectionsApi.activeRuns(connection.companyId, connection.id), }); - const accounts = useQuery({ - queryKey: ["ai-connections", connection.companyId], - queryFn: ({ signal }) => aiConnectionsApi.list(connection.companyId, undefined, { signal }), - retry: retryTransientOnly(1), + 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), queryFn: ({ signal }) => toolsApi.listConnectionGrants(connection.id, { signal }), retry: retryTransientOnly(1), }); const runsView = useQueryView(runs); + const sessionView = useQueryView(session); const accountsView = useQueryView(accounts); const grantsView = useQueryView(grants); const refresh = () => client.invalidateQueries(); - const makeDefault = useMutation({ - mutationFn: (id: string) => - aiConnectionsApi.setDefault(connection.companyId, id), - onSuccess: refresh, - }); const revoke = useMutation({ mutationFn: (id: string) => toolsApi.revokeConnectionGrant(connection.id, id), @@ -70,20 +71,27 @@ export function ManagedAiConnectionDetails({ mutationFn: (id: string) => heartbeatsApi.cancel(id), onSuccess: refresh, }); - const account = accounts.data?.connections.find( - (a) => a.id === connection.id, - ); + 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 error = makeDefault.error ?? stop.error; + const loadError = [sessionView, accountsView, grantsView].find((view) => view.kind === "error")?.error; + const error = stop.error; const loadFailure = loadError ? (

{describeError(loadError).body}

+ {afterName}
{details} {state.message ? (
; + if (props.host === "connections") return ; return ; } diff --git a/ui/storybook/prototypes/AiConnectorPages.tsx b/ui/storybook/prototypes/AiConnectorPages.tsx index 54b746e9daf..b77ba853848 100644 --- a/ui/storybook/prototypes/AiConnectorPages.tsx +++ b/ui/storybook/prototypes/AiConnectorPages.tsx @@ -14,7 +14,7 @@ import { AiConnectionAuth, type AiAuthState } from "@/components/ai-connections/ import { CredentialModeLink } from "@/components/onboarding/CredentialModeLink"; import { AI_PROVIDERS, aiMethodLabel, type AiConnectionSummary, type AiProvider, type AiAuthMethod } from "@/components/ai-connections/model"; import { AI_REVIEW_CONNECTIONS } from "../fixtures/aiConnections"; -import { storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgents, storybookAuthSession } from "../fixtures/paperclipData"; const companyId = "company-storybook"; const date = new Date("2026-09-10T12:00:00Z"); @@ -56,17 +56,19 @@ function grantsFor(account: AiConnectionSummary, readOnly: boolean): ConnectionG } /** Mount the production route components against an isolated, deterministic in-memory API. */ -export function AiConnectorPages({ initialConnections = AI_REVIEW_CONNECTIONS, detail = false, readOnly = false }: { - initialConnections?: AiConnectionSummary[]; detail?: boolean; readOnly?: boolean; +export function AiConnectorPages({ initialConnections = AI_REVIEW_CONNECTIONS, detail = false, readOnly = false, failDefaultOnce = false }: { + initialConnections?: AiConnectionSummary[]; detail?: boolean; readOnly?: boolean; failDefaultOnce?: boolean; }) { const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, refetchOnWindowFocus: false }, mutations: { retry: false } } })); const [store] = useState(() => ({ profiles: new Map(initialConnections.filter((row) => row.id === "claude-dotta").map((row) => [row.id, { id: `profile-${row.id}`, companyId, profileKey: `app:${row.id}`, entries: [], bindings: ["nova"].map((targetId) => ({ targetType: "agent", targetId })), - }])), accounts: initialConnections.map((row) => ({ ...row })), removed: new Set(), installs: new Map(), audience: new Map() })); + }])), accounts: initialConnections.map((row) => ({ ...row, companyId, canSetDefault: !readOnly && row.ownership === "personal" && row.ownerUserId === "dotta" && !row.routing })) as AiConnectionSummary[], + failedDefault: false, removed: new Set(), installs: new Map(), audience: new Map() })); const [ready, setReady] = useState(false); const [, render] = useState(0); const navigate = useNavigate(); function update(account: AiConnectionSummary) { + account = { ...account, companyId, canSetDefault: !readOnly && account.ownership === "personal" && account.ownerUserId === "dotta" && !account.routing }; store.accounts = store.accounts.some((row) => row.id === account.id) ? store.accounts.map((row) => row.id === account.id ? account : row) : [...store.accounts, account]; void client.invalidateQueries(); render((n) => n + 1); } @@ -81,9 +83,20 @@ export function AiConnectorPages({ initialConnections = AI_REVIEW_CONNECTIONS, d const rows = store.accounts.filter((row) => !store.removed.has(row.id)); const applications: ToolApplication[] = gallery.map((app) => ({ id: `app-${app.slug}`, companyId, applicationKey: `app-gallery:${app.slug}:review`, name: app.name, description: app.description, type: "mcp_http", status: "active", pluginId: null, ownerAgentId: null, ownerUserId: "dotta", metadata: { sourceTemplateKey: app.slug }, archivedAt: null, createdAt: date, updatedAt: date })); const reply = (value: unknown) => Promise.resolve(Response.json(value)); + if (path === "/api/auth/get-session") return reply({ ...storybookAuthSession, user: { ...storybookAuthSession.user, id: "dotta", name: "Dotta" } }); + if (path === `/api/companies/${companyId}/ai-connections`) return reply({ currentUserId: "dotta", canManageConnections: !readOnly, connections: rows }); + if (path === `/api/companies/${companyId}/ai-connections/default` && method === "PUT") { + const target = rows.find(row => row.grantId === payload.grantId); + if (!target?.canSetDefault || target.status !== "connected") return Response.json({ error: "This account cannot be selected as your default." }, { status: 403 }); + if (failDefaultOnce && !store.failedDefault) { store.failedDefault = true; return Response.json({ error: "The default could not be saved. Try again." }, { status: 503 }); } + store.accounts = store.accounts.map(row => row.provider === target.provider && row.ownerUserId === "dotta" ? { ...row, isDefault: row.grantId === target.grantId } : row); + return reply({ ok: true }); + } if (path === `/api/companies/${companyId}/tools/gallery`) return reply({ apps: gallery, capabilities: { canCreateOrganizationGrant: !readOnly, organizationGrantReason: null, canSetCompanyInstall: !readOnly, companyInstallReason: null } }); if (path === `/api/companies/${companyId}/tools/applications`) return reply({ applications }); if (path === `/api/companies/${companyId}/tools/connections`) return reply({ connections: rows.map(asConnection) }); + if (path === `/api/companies/${companyId}/chat-endpoints`) return reply([]); + if (path === "/api/mcp/connections") return reply([]); if (path === `/api/companies/${companyId}/tools/profiles`) return reply({ profiles: [...store.profiles.values()] }); if (path === `/api/companies/${companyId}/tools/policies`) return reply({ policies: [] }); if (path === `/api/companies/${companyId}/agents`) return reply(agents); @@ -110,6 +123,10 @@ export function AiConnectorPages({ initialConnections = AI_REVIEW_CONNECTIONS, d return reply({ connectionId: row.id, installs: store.installs.get(row.id) ?? [] }); } if (suffix === "/catalog") return reply({ catalog: [] }); + if (suffix === "/profile-configuration") { + const profile = store.profiles.get(row.id); + return reply({ profiles: profile ? [profile] : [] }); + } return Response.json({ error: `Unsupported review operation: ${suffix}` }, { status: 400 }); } if (path.startsWith(`/api/companies/${companyId}/tools/apps/`) && path.endsWith("/finish")) { @@ -135,14 +152,14 @@ export function AiConnectorPages({ initialConnections = AI_REVIEW_CONNECTIONS, d { const row = store.accounts.find((account) => account.id === connection.id); - return row ?

{aiMethodLabel(row.provider, row.method)} · {row.ownership === "shared" ? "Company shared" : "Personal"}{row.isDefault ? " · Personal default" : ""}{row.accountLabel ? ` · ${row.accountLabel}` : ""}

: null; + return row ?

{aiMethodLabel(row.provider, row.method)} · {row.ownership === "shared" ? "Company shared" : "Personal"}{row.accountLabel ? ` · ${row.accountLabel}` : ""}

: null; }} />} /> } /> navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&stage=setup&reconnect=${connection.id}`)} renderActions={(connection) => { const account = store.accounts.find((row) => row.id === connection.id); return account ? { - store.accounts = store.accounts.map((row) => row.provider === account.provider && row.method === account.method && row.ownerUserId === "dotta" ? { ...row, isDefault: row.id === account.id } : row); update({ ...account, isDefault: true }); + store.accounts = store.accounts.map((row) => row.provider === account.provider && row.ownerUserId === "dotta" ? { ...row, isDefault: row.id === account.id } : row); update({ ...account, isDefault: true }); }} onReconnect={() => navigate(`/apps/connect?source=${account.provider}&stage=setup&reconnect=${account.id}`)} onRevoke={() => update({ ...account, status: "revoked" })} diff --git a/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx b/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx index 835f6067504..1c94ef27271 100644 --- a/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx +++ b/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx @@ -1,7 +1,7 @@ import { AiReviewFrame, aiReviewContext } from "../../prototypes/AiReviewFrame"; import { AiTaskConnectionReview } from "../../prototypes/AiTaskConnectionReview"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, userEvent, within } from "storybook/test"; +import { expect, userEvent, waitFor, within } from "storybook/test"; import { AiConnectionsReview } from "../../prototypes/AiConnectionsReview"; import { AiConnectorPages } from "../../prototypes/AiConnectorPages"; import { @@ -259,12 +259,62 @@ export const ChangePersonalDefault: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(await canvas.findByRole("button", { name: "Make default" })); - await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Personal default"); - await expect(canvas.queryByRole("button", { name: "Make default" })).not.toBeInTheDocument(); + await userEvent.click(await canvas.findByRole("button", { name: "Set as default for Claude" })); + await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Default for Claude"); + await expect(canvas.queryByRole("button", { name: "Set as default for Claude" })).not.toBeInTheDocument(); }, }; +const connectorDefaultAccounts = AI_REVIEW_CONNECTIONS.map(account => account.id === "openai-api" + ? { ...account, name: "My OpenAI API key", ownership: "personal" as const, ownerUserId: "dotta", ownerName: "Dotta" } + : account); + +export const ConnectorDefaultMenu: Story = { + args: { host: "connections", initialConnections: connectorDefaultAccounts }, + parameters: { paperclip: { capabilities: ["runs-adapters/validation", "connection-setup/catalog"], status: "current", implementation: "production", boundary: "fixtures", + notes: "Production Browse/ConnectorCard crown and dropdown. Fixture API persists one default per owner/provider across methods; provider authentication and real API authorization are not exercised." } }, + play: async ({ canvasElement }) => { + const heading = await within(canvasElement).findByRole("heading", { name: "OpenAI" }); + const provider = within(heading.closest('[data-app-slug="openai"]')!); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(await provider.findByRole("button", { name: "2 connections" })); + await expect(provider.getByLabelText("Your default for OpenAI")).toBeVisible(); + await userEvent.click(provider.getByRole("button", { name: "Manage My OpenAI API key connection" })); + await userEvent.click(await body.findByRole("menuitem", { name: "Set as default for OpenAI" })); + await waitFor(() => expect(provider.getByLabelText("Your default for OpenAI").parentElement).toHaveTextContent("My OpenAI API key")); + await userEvent.click(provider.getByRole("button", { name: "Manage My OpenAI API key connection" })); + await expect(await body.findByRole("menuitem", { name: "Default for OpenAI" })).toHaveAttribute("aria-disabled", "true"); + await userEvent.keyboard("{Escape}"); + await expect(provider.getByRole("button", { name: "Manage My OpenAI API key connection" })).toHaveFocus(); + }, +}; + +export const ConnectorDefaultRetry: Story = { + args: { host: "connections", initialConnections: connectorDefaultAccounts, failDefaultOnce: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const heading = await canvas.findByRole("heading", { name: "OpenAI" }); + const provider = within(heading.closest('[data-app-slug="openai"]')!); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(await provider.findByRole("button", { name: "2 connections" })); + await userEvent.click(provider.getByRole("button", { name: "Manage My OpenAI API key connection" })); + await userEvent.click(await body.findByRole("menuitem", { name: "Set as default for OpenAI" })); + const failure = (await canvas.findByText("Couldn’t change your OpenAI default.")).closest('[role="alert"]')!; + await expect(failure).toHaveTextContent("Couldn’t change your OpenAI default"); + await expect(provider.getByLabelText("Your default for OpenAI").parentElement).toHaveTextContent("My ChatGPT subscription"); + await userEvent.click(within(failure).getByRole("button", { name: "Retry" })); + await waitFor(() => expect(provider.getByLabelText("Your default for OpenAI").parentElement).toHaveTextContent("My OpenAI API key")); + }, +}; + +export const ConnectorDefaultUnavailable: Story = { + args: { host: "connections", initialConnections: connectorDefaultAccounts.map(account => account.id === "openai-personal" ? { ...account, status: "expired" } : account) }, +}; + +export const ConnectorDefaultsReadOnly: Story = { + args: { host: "connections", initialConnections: connectorDefaultAccounts, readOnly: true }, +}; + const authArgs = { initialStage: "auth" as const }; const waiting = { phase: "waiting" as const, @@ -522,7 +572,7 @@ export const ReconnectExisting: Story = { await userEvent.click(canvas.getByRole("button", { name: "Submit code" })); await userEvent.click(canvas.getByRole("button", { name: "Use connection" })); await expect(await canvas.findByRole("heading", { name: "My Claude subscription" })).toBeVisible(); - await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Personal default"); + await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Default for Claude"); }, }; export const RevokeConnection: Story = {