feat(connectors): show and select personal AI account defaults (#19845)

Show a crown on each saved personal AI provider default. Add a provider-specific menu action to other eligible accounts. Share saved-state queries, block duplicate writes, and keep the saved crown until a write succeeds.

Keep account details available for accessible grants. Show session lookup errors with Retry. Resolve list permissions once per request and index account lookups. Add UI and API regression coverage and account documentation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-07 10:26:08 -06:00
1 parent 824b25654c
commit 377bc87df1
18 files changed
+742 -59

No files matched your search

+20
View File
@@ -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.
+1
View File
@@ -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;
@@ -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();
+18 -5
View File
@@ -227,15 +227,28 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
const agentId = req.query.agentId;
if (agentId !== undefined && !z.string().uuid().safeParse(agentId).success)
throw unprocessable("Invalid agent ID");
const accounts = await filterConnectionManagementRows(db, req, "connections:read", await service.list(
companyId, currentUserId, agentId as string | undefined,
), account => ({ 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(
+100 -2
View File
@@ -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<void>(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 => {
+6 -1
View File
@@ -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,
@@ -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<void>;
@@ -23,22 +26,23 @@ 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",
)}>
<div className="flex items-center gap-3">
<Star aria-hidden className={cn("size-5 shrink-0", activeDefault ? "fill-current text-(--status-task-icon-done)" : "text-muted-foreground")} />
<h3 role={account.isDefault ? "status" : undefined} className={cn("text-sm font-semibold", account.isDefault && !available && "text-destructive")}>{account.isDefault && !available ? "Default unavailable" : "Personal default"}</h3>
{account.isDefault && <AiDefaultCrown account={account} />}
<h3 role={account.isDefault ? "status" : undefined} className={cn("text-sm font-semibold", account.isDefault && !available && "text-destructive")}>{account.isDefault ? available ? `Default for ${providerName}` : "Default unavailable" : `Your ${providerName} default`}</h3>
</div>
{!account.isDefault && (!readOnly ? (
<Button variant="outline" size="sm" disabled={!available} onClick={onMakeDefault}>Make default</Button>
{!account.isDefault && (!readOnly && account.canSetDefault ? (
<Button variant="outline" size="sm" disabled={!available || Boolean(account.unavailableReason) || defaultPending} onClick={onMakeDefault}>Set as default for {providerName}</Button>
) : <span className="text-xs text-muted-foreground">Not default</span>)}
</div>
)}
@@ -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(<QueryClientProvider client={client}>{completePanel
root.render(<QueryClientProvider client={client}><TooltipProvider>{completePanel
? <ManagedAiConnectionDetails connection={{ id: value.id, companyId: value.companyId } as ToolConnection} />
: <AiConnectionAccountDetails account={value} grant={selectedGrant} />}</QueryClientProvider>);
: <AiConnectionAccountDetails account={value} grant={selectedGrant} />}</TooltipProvider></QueryClientProvider>);
});
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;
@@ -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<AiManagedConnectionSummary, "provider"> }) {
const [open, setOpen] = useState(false);
const label = `Your default for ${AI_PROVIDERS[account.provider].name}`;
return <Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>
<button type="button" aria-label={label} className="shrink-0 rounded-sm text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={event => { event.preventDefault(); setOpen(true); }}>
<Crown className="size-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>;
}
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 <DropdownMenuItem disabled={account.isDefault || pending || Boolean(reason)} onSelect={onSelect}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Crown aria-hidden="true" />}
<span className="flex min-w-0 flex-col gap-1">
<span>{account.isDefault ? `Default for ${provider}` : `Set as default for ${provider}`}</span>
{!account.isDefault && reason && <span className="text-xs text-muted-foreground">{reason}</span>}
</span>
</DropdownMenuItem>;
}
export function AiDefaultError({ provider, pending, onRetry }: {
provider: string; pending: boolean; onRetry: () => void;
}) {
return <div role="alert" className="flex flex-wrap items-center gap-2 text-sm text-destructive">
<p>Couldn’t change your {provider} default.</p>
<Button variant="outline" size="sm" disabled={pending} onClick={onRetry}>Retry</Button>
</div>;
}
@@ -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 }) => (
<section aria-label="AI account settings">{account.name}</section>
AiConnectionAccountControls: ({ account, grant }: { account: AiManagedConnectionSummary; grant: { id: string } }) => (
<section aria-label="AI account settings" data-grant-id={grant.id}>{account.name}</section>
),
}));
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<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";
@@ -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(<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 () => {
@@ -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<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 };
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();
@@ -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 ? (
<div role="alert" className="space-y-2">
<p className="text-sm text-destructive">{describeError(loadError).body}</p>
<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();
}}
@@ -96,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>
@@ -105,13 +113,16 @@ export function ManagedAiConnectionDetails({
<div className="space-y-4">
{loadFailure}
{error && <p role="alert" className="text-sm text-destructive">{describeError(error).body}</p>}
{defaults.error && defaults.failedProvider && <AiDefaultError provider={AI_PROVIDERS[defaults.failedProvider].name}
pending={defaults.pending(defaults.failedProvider)} onRetry={defaults.retry} />}
<AiConnectionAccountDetails account={account} grant={grant} />
{account.routing && <details className="rounded-lg border border-border p-4"><summary className="cursor-pointer text-sm font-medium">Provider settings</summary><dl className="mt-4 grid grid-cols-2 gap-3 text-sm"><dt className="text-muted-foreground">Destination</dt><dd className="break-all">{account.routing.baseUrl ?? (account.routing.kind === "bedrock" ? account.routing.region : "OpenRouter")}</dd><dt className="text-muted-foreground">API format</dt><dd>{account.routing.kind === "openrouter" ? "Selected by harness" : account.routing.protocol}</dd><dt className="text-muted-foreground">Authentication</dt><dd>{account.routing.auth}</dd><dt className="text-muted-foreground">Models</dt><dd>{account.routing.models.map(m => m.label ?? m.id).join(", ") || "Enter a model ID on the agent"}</dd></dl></details>}
<AiConnectionAccountControls
account={account}
grant={grant}
currentUserId={accounts.data!.currentUserId}
onMakeDefault={() => makeDefault.mutate(grant.id)}
defaultPending={defaults.pending(account.provider)}
onMakeDefault={() => defaults.choose(account)}
onRevoke={() => revoke.mutateAsync(grant.id).then(() => undefined)}
revocationDetails={
<div className="space-y-2">
@@ -0,0 +1,104 @@
import { useEffect, useMemo, useRef } from "react";
import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AiConnectionList, AiManagedConnectionSummary, AiProvider, ToolConnection } from "@paperclipai/shared";
import { aiConnectionRouterPluginKey } from "@paperclipai/shared";
import { aiConnectionsApi } from "@/api/ai-connections";
import { describeError } from "@/api/errors";
import { useOptionalToastActions } from "@/context/ToastContext";
import { retryTransientOnly } from "@/lib/query-client";
import { AI_PROVIDERS } from "./model";
export function isOwnAiAccount(account: Pick<AiManagedConnectionSummary, "ownership" | "ownerUserId" | "routing">, userId: string) {
return account.ownership === "personal" && account.ownerUserId === userId && !account.routing;
}
export function indexAiAccounts(accounts: AiManagedConnectionSummary[], userId: string) {
const groups = new Map<string, AiManagedConnectionSummary[]>();
for (const account of accounts) {
if (!isOwnAiAccount(account, userId)) continue;
const key = JSON.stringify([account.companyId, account.id]);
const group = groups.get(key);
if (group) group.push(account);
else groups.set(key, [account]);
}
return new Map([...groups].map(([key, candidates]) => [key,
candidates.length === 1 ? candidates[0] : candidates.find(account => account.isDefault),
]));
}
type DefaultTarget = { account: AiManagedConnectionSummary; retry: boolean; scope: string };
export function useAiConnectionDefaults(companyId: string | null, identity: { userId: string | null; settled: boolean }, enabled = true) {
const client = useQueryClient();
const toast = useOptionalToastActions();
const scope = JSON.stringify([companyId, identity.userId, identity.settled]);
const activeScope = useRef<string | null>(scope);
activeScope.current = scope;
useEffect(() => {
activeScope.current = scope;
return () => { activeScope.current = null; };
}, [scope]);
const listKey = ["ai-connections", companyId, undefined, identity.userId];
const accounts = useQuery({
queryKey: listKey,
queryFn: async ({ signal }) => {
const result = await aiConnectionsApi.list(companyId!, undefined, { signal, fresh: true });
if (identity.userId && result.currentUserId !== identity.userId) throw new Error("Your account changed. Refresh your connections.");
return result;
},
enabled: Boolean(companyId) && identity.settled && enabled,
retry: retryTransientOnly(1),
refetchOnWindowFocus: "always",
});
const mutationKey = ["ai-default", companyId, identity.userId];
const pendingProviders = useMutationState({
filters: { mutationKey, status: "pending" },
select: mutation => (mutation.state.variables as DefaultTarget).account.provider,
});
const mutation = useMutation({
mutationKey,
mutationFn: async (target: DefaultTarget) => {
const snapshot = target.retry ? (await accounts.refetch({ throwOnError: true })).data : accounts.data;
const account = snapshot?.connections.find(candidate => candidate.id === target.account.id && candidate.grantId === target.account.grantId);
if (activeScope.current !== target.scope || !identity.settled || !snapshot || !account ||
account.companyId !== companyId || !isOwnAiAccount(account, snapshot.currentUserId) ||
!account.canSetDefault || account.status !== "connected" || account.unavailableReason) {
throw new Error("This account is unavailable as your default. Refresh or reconnect it, then try again.");
}
if (!account.isDefault) await aiConnectionsApi.setDefault(account.companyId, account.grantId);
},
onSuccess: async (_result, target) => {
await client.invalidateQueries({ queryKey: ["ai-connections", target.account.companyId] });
if (activeScope.current !== target.scope) return;
const saved = client.getQueryData<AiConnectionList>(listKey)?.connections.find(account => account.grantId === target.account.grantId);
toast?.pushToast(saved?.isDefault
? { tone: "success", title: `${target.account.name} is now your ${AI_PROVIDERS[target.account.provider].name} default` }
: { tone: "info", title: `Your ${AI_PROVIDERS[target.account.provider].name} default was updated`, body: "The latest saved selection is shown in Connectors." });
},
onError: (error, target) => {
if (activeScope.current !== target.scope) return;
toast?.pushToast({ tone: "error", title: `Couldn’t change your ${AI_PROVIDERS[target.account.provider].name} default`,
body: describeError(error).body, action: { label: "Retry", onClick: () => choose(target.account, true, target.scope) } });
},
});
function choose(account: AiManagedConnectionSummary, retry = false, targetScope = scope) {
if (activeScope.current !== targetScope || !identity.settled || !companyId) return;
if (client.getMutationCache().findAll({ mutationKey, status: "pending" }).some(
entry => (entry.state.variables as DefaultTarget).account.provider === account.provider,
)) return;
mutation.mutate({ account, retry, scope: targetScope });
}
const data = identity.settled && (!identity.userId || accounts.data?.currentUserId === identity.userId) ? accounts.data : undefined;
const indexedAccounts = useMemo(() => data ? indexAiAccounts(data.connections, data.currentUserId) : undefined, [data]);
return {
accounts,
data,
accountForConnection: (connection: ToolConnection) => connection.connectionPurpose === "ai" && !aiConnectionRouterPluginKey(connection)
? indexedAccounts?.get(JSON.stringify([connection.companyId, connection.id])) : undefined,
choose,
pending: (provider: AiProvider) => pendingProviders.includes(provider),
error: mutation.variables?.scope === scope ? mutation.error : null,
failedProvider: mutation.variables?.account.provider,
retry: () => { if (mutation.variables) choose(mutation.variables.account, true, mutation.variables.scope); },
};
}
+1
View File
@@ -181,6 +181,7 @@ vi.mock("@/context/BreadcrumbContext", () => ({
const pushToastMock = vi.hoisted(() => vi.fn());
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
useOptionalToastActions: () => ({ pushToast: pushToastMock }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+156
View File
@@ -9,6 +9,7 @@ import { ApiError } from "@/api/client";
import { aiConnectionRouterAppDefinition, CONNECTABLE_APP_DEFINITIONS, getAppStoreDefinition } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { AiManagedConnectionSummary } from "@paperclipai/shared";
import type { AggregatorAppCatalogEntry } from "@paperclipai/shared/aggregator-app-catalog";
const aggregatorCatalogMock = vi.hoisted(() => [] as AggregatorAppCatalogEntry[]);
@@ -58,6 +59,9 @@ vi.mock("@/api/publicMcp", () => ({ publicMcpApi: { connections: assistantConnec
const listGalleryMock = vi.hoisted(() => vi.fn());
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listAiConnectionsMock = vi.hoisted(() => vi.fn());
const setAiDefaultMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: { list: listAiConnectionsMock, setDefault: setAiDefaultMock } }));
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
const archiveConnectionMock = vi.hoisted(() => vi.fn());
const pushToastMock = vi.hoisted(() => vi.fn());
@@ -118,6 +122,7 @@ vi.mock("@/context/BreadcrumbContext", () => ({
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
useOptionalToastActions: () => ({ pushToast: pushToastMock }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -231,6 +236,8 @@ describe("Connectors landing page", () => {
});
listApplicationsMock.mockResolvedValue({ applications: [] });
listConnectionsMock.mockResolvedValue({ connections: [] });
listAiConnectionsMock.mockReset().mockResolvedValue({ currentUserId: "board-user", canManageConnections: true, connections: [] });
setAiDefaultMock.mockReset().mockResolvedValue({ ok: true });
listUserDirectoryMock.mockResolvedValue({ users: [] });
archiveConnectionMock.mockResolvedValue(connection({ status: "archived" }));
container = document.createElement("div");
@@ -1835,6 +1842,155 @@ describe("Connectors landing page", () => {
expect(container.textContent).not.toContain("All apps");
});
function aiFixture(overrides: Partial<AiManagedConnectionSummary>[] = []) {
let accounts: AiManagedConnectionSummary[] = [
{ id: "openai-sub", grantId: "grant-sub", companyId: "company-1", provider: "openai", method: "subscription", name: "My ChatGPT subscription", ownership: "personal", ownerUserId: "board-user", isDefault: true, canSetDefault: true, status: "connected" },
{ id: "openai-key", grantId: "grant-key", companyId: "company-1", provider: "openai", method: "api_key", name: "My OpenAI API key", ownership: "personal", ownerUserId: "board-user", isDefault: false, canSetDefault: true, status: "connected" },
{ id: "claude-key", grantId: "grant-claude", companyId: "company-1", provider: "anthropic", method: "api_key", name: "My Claude API key", ownership: "personal", ownerUserId: "board-user", isDefault: true, canSetDefault: true, status: "connected" },
].map((account, index) => ({ ...account, ...overrides[index] })) as AiManagedConnectionSummary[];
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("openai"), getAppStoreDefinition("anthropic")] });
listApplicationsMock.mockResolvedValue({ applications: ["openai", "anthropic"].map(provider => application({ id: `app-${provider}`, name: provider, applicationKey: `app-gallery:${provider}:one`, metadata: { sourceTemplateKey: provider } })) });
listConnectionsMock.mockResolvedValue({ connections: accounts.map(account => connection({ id: account.id, companyId: account.companyId, applicationId: `app-${account.provider}`, connectionPurpose: "ai", credentialPolicy: account.ownership === "personal" ? "per_user" : "shared", name: account.name,
config: { sourceTemplateKey: account.provider, ai: { provider: account.provider, method: account.method } } })) });
listAiConnectionsMock.mockImplementation(async () => ({ currentUserId: "board-user", canManageConnections: true, connections: accounts }));
const save = async (_companyId: string, grantId: string) => {
const target = accounts.find(account => account.grantId === grantId)!;
accounts = accounts.map(account => account.provider === target.provider && account.ownerUserId === target.ownerUserId
? { ...account, isDefault: account.grantId === grantId } : account);
return { ok: true };
};
setAiDefaultMock.mockImplementation(save);
return { save, replace: (next: AiManagedConnectionSummary[]) => { accounts = next; }, accounts: () => accounts };
}
async function aiMenu(name = "My OpenAI API key") {
const row = container.querySelector('[data-app-slug="openai"]')!;
if (!row.querySelector(`[aria-label="Manage ${name} connection"]`)) await clickButton("2 connections", row);
await act(() => { row.querySelector(`[aria-label="Manage ${name} connection"]`)!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
return Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(item => item.textContent?.includes("default for OpenAI") || item.textContent === "Default for OpenAI");
}
it("bounds account lookup work when a large provider group expands and saves", async () => {
const fixture = aiFixture();
const count = 400;
let reads = 0;
const accounts = Array.from({ length: count }, (_, index) => ({
...fixture.accounts()[1],
get id() {
if (++reads > count * 30) throw new Error("Account lookup exceeded its linear work budget");
return `large-account-${index}`;
},
grantId: `large-grant-${index}`, name: `OpenAI account ${index}`, isDefault: index === 0,
}));
fixture.replace(accounts);
listConnectionsMock.mockResolvedValue({ connections: accounts.map(account => connection({
id: account.id, companyId: account.companyId, applicationId: "app-openai", connectionPurpose: "ai", credentialPolicy: "per_user", name: account.name,
config: { sourceTemplateKey: "openai", ai: { provider: "openai", method: "api_key" } },
})) });
setAiDefaultMock.mockImplementation(() => new Promise(() => {}));
reads = 0;
await renderBrowse();
const row = container.querySelector('[data-app-slug="openai"]')!;
await clickButton(`${count} connections`, row);
expect(row.querySelectorAll('[aria-label^="Manage OpenAI account"]')).toHaveLength(count);
expect(row.querySelector('[aria-label="Your default for OpenAI"]')).not.toBeNull();
await act(() => { row.querySelector('[aria-label="Manage OpenAI account 1 connection"]')!
.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
const item = Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find(element => element.textContent === "Set as default for OpenAI")!;
await act(() => item.click());
await flushReact();
expect(setAiDefaultMock).toHaveBeenCalledExactlyOnceWith("company-1", "large-grant-1");
expect(reads).toBeLessThanOrEqual(count * 30);
});
it("sets a provider default by grant, keeps the old crown pending, and refetches the saved choice", async () => {
const fixture = aiFixture();
let release!: () => void;
setAiDefaultMock.mockImplementation((company, grant) => new Promise<void>(resolve => { release = () => { void fixture.save(company, grant); resolve(); }; }));
const client = await renderBrowse();
await flushReact();
const item = await aiMenu();
const oldCrown = container.querySelector('[aria-label="Your default for OpenAI"]')!;
expect(oldCrown.parentElement?.textContent).toContain("My ChatGPT subscription");
expect(item?.textContent).toBe("Set as default for OpenAI");
await act(() => item!.click());
await flushReact();
expect(setAiDefaultMock).toHaveBeenCalledExactlyOnceWith("company-1", "grant-key");
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My ChatGPT subscription");
expect((await aiMenu())?.getAttribute("aria-disabled")).toBe("true");
await act(() => release());
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My OpenAI API key");
expect(container.querySelector('[aria-label="Your default for Claude"]')?.parentElement?.textContent).toContain("My Claude API key");
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ tone: "success", title: "My OpenAI API key is now your OpenAI default" }));
await act(async () => { await client.invalidateQueries({ queryKey: ["ai-connections", "company-1"] }); });
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My OpenAI API key");
});
it("retains a failed selection and rechecks the same grant before an explicit retry", async () => {
const fixture = aiFixture();
setAiDefaultMock.mockRejectedValueOnce(new Error("Save failed"));
await renderBrowse();
const item = await aiMenu();
await act(() => item!.click());
await flushReact();
expect(container.textContent).toContain("Couldn’t change your OpenAI default");
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My ChatGPT subscription");
const before = listAiConnectionsMock.mock.calls.length;
fixture.replace(fixture.accounts().map(account => account.grantId === "grant-key" ? { ...account, status: "revoked" } : account));
await clickButton("Retry", container);
expect(listAiConnectionsMock.mock.calls.length).toBeGreaterThan(before);
expect(setAiDefaultMock).toHaveBeenCalledTimes(1);
fixture.replace(fixture.accounts().map(account => ({ ...account, status: "connected" })));
await clickButton("Retry", container);
expect(setAiDefaultMock).toHaveBeenCalledTimes(2);
expect(setAiDefaultMock).toHaveBeenLastCalledWith("company-1", "grant-key");
});
it.each([
{ ownership: "shared", ownerUserId: undefined },
{ ownerUserId: "another-person" },
{ canSetDefault: false },
{ routing: { kind: "openrouter", auth: "bearer", models: [] } },
] as Partial<AiManagedConnectionSummary>[])("does not offer personal-default actions for an ineligible account: %j", async override => {
aiFixture([{}, override]);
await renderBrowse();
expect(await aiMenu()).toBeUndefined();
expect(setAiDefaultMock).not.toHaveBeenCalled();
});
it("keeps an unavailable default crowned and disables selection of an unavailable replacement", async () => {
aiFixture([{ status: "needs_attention" }, { status: "expired" }]);
await renderBrowse();
const item = await aiMenu();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My ChatGPT subscription");
expect(container.textContent).toContain("Default unavailable");
expect(item?.getAttribute("aria-disabled")).toBe("true");
expect(item?.textContent).toContain("Reconnect this account");
});
it("refetches cross-tab changes on focus and drops the previous user's crowns on an identity change", async () => {
const fixture = aiFixture();
const client = await renderBrowse();
await aiMenu();
await act(() => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); });
await fixture.save("company-1", "grant-key");
await act(() => { focusManager.setFocused(false); focusManager.setFocused(true); });
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My OpenAI API key");
accountIdentity.userId = "another-person";
listAiConnectionsMock.mockImplementation(() => new Promise(() => {}));
await act(() => { root.render(<QueryClientProvider client={client}><TooltipProvider><Browse /></TooltipProvider></QueryClientProvider>); });
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')).toBeNull();
expect(container.querySelector('[aria-label="Your default for Claude"]')).toBeNull();
focusManager.setFocused(undefined);
});
it("shows existing accounts and an actionable warning when the gallery request fails", async () => {
listGalleryMock.mockRejectedValue(new Error("Gallery unavailable"));
listApplicationsMock.mockResolvedValue({
+26 -4
View File
@@ -13,6 +13,9 @@ import {
} from "@paperclipai/shared";
import { AssistantConnectionCard, useAssistantConnections } from "./AssistantConnection";
import { ManagedAiConnectionRow } from "@/components/ai-connections/ManagedAiConnectionDetails";
import { useAiConnectionDefaults } from "@/components/ai-connections/useAiConnectionDefaults";
import { AiDefaultCrown, AiDefaultError, AiDefaultMenuItem } from "@/components/ai-connections/AiConnectionDefault";
import { AI_PROVIDERS } from "@/components/ai-connections/model";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -438,6 +441,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const aiDefaults = useAiConnectionDefaults(selectedCompanyId, { userId: viewingUserId, settled: identitySettled },
connectionsQuery.data?.connections.some(connection => connection.connectionPurpose === "ai" && !aiConnectionRouterPluginKey(connection)) ?? false);
const aiDefaultsView = useQueryView(aiDefaults.accounts);
const chatEndpointsQuery = useQuery({
queryKey: queryKeys.chatEndpoints.list(selectedCompanyId ?? "__none__"),
queryFn: () => chatEndpointsApi.list(selectedCompanyId!),
@@ -1013,6 +1019,10 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
) : null}
{refreshAggregator.error ? <p role="alert" className="text-sm text-destructive">Couldn’t refresh the gateway. Last known accounts are shown.</p> : null}
{aiDefaultsView.kind === "error" && <QueryErrorState size="inline" error={aiDefaultsView.error} action="load AI account defaults"
onRetry={aiDefaultsView.retry} retrying={aiDefaultsView.isFetching} />}
{aiDefaults.error && aiDefaults.failedProvider && <AiDefaultError provider={AI_PROVIDERS[aiDefaults.failedProvider].name}
pending={aiDefaults.pending(aiDefaults.failedProvider)} onRetry={aiDefaults.retry} />}
{loading ? (
<div className="space-y-3" aria-label="Loading connectors">
@@ -1031,6 +1041,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
{paginatedRows.map((row) => (
<ConnectorCard
renderAccountDetails={renderAccountDetails}
aiDefaults={aiDefaults}
key={row.key}
row={row}
userProfileById={userProfileById}
@@ -1134,6 +1145,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
export function ConnectorCard({
renderAccountDetails,
aiDefaults,
row,
userProfileById,
chatAgentById,
@@ -1157,6 +1169,7 @@ export function ConnectorCard({
searchQuery = "",
}: {
renderAccountDetails?: (connection: ToolConnection) => ReactNode;
aiDefaults?: ReturnType<typeof useAiConnectionDefaults>;
row: ConnectorRowModel;
userProfileById: ReadonlyMap<string, ConnectionOwnerProfile>;
chatAgentById?: ReadonlyMap<string, AvatarAgent>;
@@ -1286,6 +1299,7 @@ export function ConnectorCard({
{row.connections.map((connection) => (
<ConnectionAccountRow
details={renderAccountDetails?.(connection)}
aiDefaults={aiDefaults}
key={connection.id}
row={row}
setupEnabled={setupEnabled}
@@ -1428,6 +1442,7 @@ export function ConnectorCard({
function ConnectionAccountRow({
details,
aiDefaults,
row,
connection,
owner,
@@ -1441,6 +1456,7 @@ function ConnectionAccountRow({
setupEnabled = true,
}: {
details?: ReactNode;
aiDefaults?: ReturnType<typeof useAiConnectionDefaults>;
row: ConnectorRowModel;
connection: ToolConnection;
owner: ConnectionOwnerProfile | null;
@@ -1460,9 +1476,12 @@ function ConnectionAccountRow({
row.name,
owner,
);
const account = aiDefaults?.accountForConnection(connection);
return (
<ConnectionAccountRowLayout state={state} accountName={accountName} owner={owner} details={<>{details}{discovery?.discovery.message || discovery?.sync.status === "error" ? <p className="text-xs text-muted-foreground" role="status">{discovery.discovery.message ?? discovery.sync.error}</p> : null}</>}
<ConnectionAccountRowLayout state={state} accountName={accountName} owner={owner}
afterName={account?.isDefault ? <AiDefaultCrown account={account} /> : undefined}
details={<>{details}{account?.isDefault && account.status !== "connected" && <p className="text-xs text-destructive">Default unavailable</p>}{discovery?.discovery.message || discovery?.sync.status === "error" ? <p className="text-xs text-muted-foreground" role="status">{discovery.discovery.message ?? discovery.sync.error}</p> : null}</>}
openLabel={`Open ${accountName} permissions`} onOpen={() => onNavigate(`/apps/${connection.id}/permissions`)}>
{setupEnabled && (state.kind === "attention" || state.kind === "draft") ? (
<Button
@@ -1490,6 +1509,8 @@ function ConnectionAccountRow({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{account?.canSetDefault && aiDefaults && !aiDefaults.accounts.error && <AiDefaultMenuItem account={account}
pending={aiDefaults.pending(account.provider)} onSelect={() => aiDefaults.choose(account)} />}
<DropdownMenuItem
onSelect={() => onNavigate(`/apps/${connection.id}/permissions`)}
>
@@ -1513,8 +1534,9 @@ function ConnectionAccountRow({
}
/** Shared presentation for native and aggregator-backed accounts. */
function ConnectionAccountRowLayout({ state, accountName, owner, source, details, openLabel, onOpen, children }: {
function ConnectionAccountRowLayout({ state, accountName, afterName, owner, source, details, openLabel, onOpen, children }: {
state: ConnectionState; accountName: string; owner?: ConnectionOwnerProfile | null; source?: ReactNode; details?: ReactNode;
afterName?: ReactNode;
openLabel: string; onOpen: () => void; children: ReactNode;
}) {
return (
@@ -1522,14 +1544,14 @@ function ConnectionAccountRowLayout({ state, accountName, owner, source, details
<div className="flex min-w-0 flex-1 items-start gap-2.5">
<ConnectionStatusIcon state={state} />
<div className="min-w-0">
<button
<div className="flex min-w-0 items-center gap-2"><button
type="button"
className="block max-w-full cursor-pointer truncate text-left text-sm font-medium text-foreground hover:underline focus-visible:underline"
aria-label={openLabel}
onClick={onOpen}
>
{accountName}
</button>
</button>{afterName}</div>
{details}
{state.message ? (
<div
@@ -35,6 +35,7 @@ export interface AiConnectionsReviewProps {
currentUserId?: string;
initialAuthState?: AiAuthState;
failFirstAttempt?: boolean;
failDefaultOnce?: boolean;
readOnly?: boolean;
loading?: boolean;
error?: string;
@@ -42,7 +43,7 @@ export interface AiConnectionsReviewProps {
/** Storybook-only orchestration. Explicit simulator controls; no provider/network transport. */
export function AiConnectionsReview(props: AiConnectionsReviewProps) {
if (props.host === "connections") return <AiConnectorPages initialConnections={props.initialConnections} detail={props.initialStage === "manage"} readOnly={props.readOnly} />;
if (props.host === "connections") return <AiConnectorPages initialConnections={props.initialConnections} detail={props.initialStage === "manage"} readOnly={props.readOnly} failDefaultOnce={props.failDefaultOnce} />;
return <AgentConnectionReview {...props} />;
}
+23 -6
View File
@@ -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<string, unknown>(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<string>(), installs: new Map<string, unknown[]>(), audience: new Map<string, string[]>() }));
}])), accounts: initialConnections.map((row) => ({ ...row, companyId, canSetDefault: !readOnly && row.ownership === "personal" && row.ownerUserId === "dotta" && !row.routing })) as AiConnectionSummary[],
failedDefault: false, removed: new Set<string>(), installs: new Map<string, unknown[]>(), audience: new Map<string, string[]>() }));
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
<Routes>
<Route path="/:companyPrefix/apps" element={<Browse renderAccountDetails={(connection) => {
const row = store.accounts.find((account) => account.id === connection.id);
return row ? <p className="text-xs text-muted-foreground">{aiMethodLabel(row.provider, row.method)} · {row.ownership === "shared" ? "Company shared" : "Personal"}{row.isDefault ? " · Personal default" : ""}{row.accountLabel ? ` · ${row.accountLabel}` : ""}</p> : null;
return row ? <p className="text-xs text-muted-foreground">{aiMethodLabel(row.provider, row.method)} · {row.ownership === "shared" ? "Company shared" : "Personal"}{row.accountLabel ? ` · ${row.accountLabel}` : ""}</p> : null;
}} />} />
<Route path="/:companyPrefix/apps/connect" element={<Setup accounts={store.accounts} onSave={update} />} />
<Route path="/:companyPrefix/apps/:connectionId/:tab" element={<AppDetail onReconnect={(connection) => 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 ? <AiReviewBoundary label="App component: AI account controls inside existing AppDetail"><AiConnectionAccountControls account={account} currentUserId="dotta" grant={grantsFor(account, readOnly).grants[0]} readOnly={readOnly}
onMakeDefault={() => {
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" })}
@@ -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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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 = {