feat: reuse provider sign-in across AI connection workflows (#13248)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Users connect provider accounts during onboarding and agent setup. > - They should reuse and manage those accounts through the existing Connectors interface. > - A second login wizard would diverge from the established provider workflows. > - This pull request composes the existing sign-in components into Connections and agent configuration. > - Users can select accounts without changing their agent's harness or model. ## Linked Issues or Issue Description **Problem or motivation** AI credentials are configured separately from Connections. Agents cannot consistently reuse a responsible user's account or a permitted shared account. **Proposed solution** Manage AI accounts with the existing Connections grants and permissions. Keep model and harness selection independent from credential selection. Preserve legacy authentication until validated adoption. **Alternatives considered** A separate credential registry would duplicate ownership and access policy. Automatic fallback would risk using the wrong account. **Roadmap alignment** This extends the shipped Apps, multi-user, secrets, and agent-runtime capabilities. The maintainer requested the feature and reviewed the UI. Related groundwork: #11899 (connection permissions), #10910 (connection wizard), #11692 (Claude subscription profiles), and #11854 (Codex account rotation). ## What Changed - Add compact AI-account management to the existing Connectors pages. - Reuse AgentProviderConnection, AdapterLoginPanel, AdapterLoginChrome, and authentication controllers. - Add the shared connection picker to agent setup/settings and task requests. - Preserve onboarding's sequence and reuse existing accounts. - Add local-login recovery, retry, cancellation, and React StrictMode handling. - Add interactive Storybook scenarios, design-guide examples, and app acceptance checks. This is part 2 of the AI Connections change. The runtime foundation in #13247 is merged. This PR now targets master. ## Verification - Updated against master `47ded8bf9`, including the landed runtime foundation and upstream task-search changes. - Full workspace typecheck, production build, Storybook build, and token gates passed on the integrated branch. Final local-login changes passed 59 focused tests; new-agent and inbox regression suites passed 63 tests. - Browser checks verified automatic local Claude account detection, resumable Codex login commands, retry, focus restoration, and desktop/phone layouts. Commands create their isolated directory before invoking the CLI. - All CI test, browser, build, packaging, and runner jobs passed on final head `dd17d3211931dd70aaa6ea619d83a7f9966dd18e`. The fresh Greptile review is 5/5, the security scan passed, and there are no unresolved review threads. The final CI aggregate gates passed. - Local general-server coverage passed 11,804 tests; three port-collision failures passed in an isolated 25-test rerun. All 6,111 UI tests passed. CLI coverage passed 484 tests; its remaining doctor test requires port 3199, which is occupied by an unrelated report server on this Mac. The complete CLI suite passed in CI. - Live browser testing verified Codex API-key reconnect inside a task card on desktop and phone. Real provider runs resumed and completed with unchanged connection/grant identity and agent routing. - Tested opening, cancelling, reopening, and completing connection creation. A regression confirms Connect another account cannot submit the new-agent form or copy provider keys into agent settings. - Added shared inline repair, automatic local sign-in checks, and responsive connection dialogs. Standalone Daytona installation ignores workspace configuration and suppresses dependency scripts. Its standalone build also passed with CI's exact pnpm 9.15.4. - Destructive live tests are excluded by default. Explicit opt-in, local deployment checks, and matching disposable fixture identities are required before any mutation. ## Risks - Local Codex/Grok creation requires the connection-specific terminal login command. - Browser sign-in uses the existing supported-environment controllers. - This update verifies live local Claude detection and Codex API-key task repair. New subscription authorization/refresh and independent-human/native-runner isolation were not reverified in this update. - No agent automatically adopts managed Connections. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and browser testing. The exact runtime model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
47ded8bf97
commit
8d6232e7b0
|
|
@ -209,3 +209,16 @@ does not delete a directory referenced by a copied command. **Start sign-in agai
|
|||
explicitly cancels the old attempt; abandoned attempts expire after 30 minutes.
|
||||
Commands create their directory if necessary, and completed/expired attempts are
|
||||
cleaned up through the existing lifecycle.
|
||||
|
||||
### Disposable live inline-repair test
|
||||
|
||||
The normal app test configuration excludes `*.live.spec.ts`. To run the destructive
|
||||
inline-repair scenario, set `AI_REPAIR_TEST_ALLOW_DESTRUCTIVE=1` and use a separate
|
||||
loopback `local_trusted` instance. Set `AI_REPAIR_TEST_DISPOSABLE_MARKER` to a fresh
|
||||
32-character lowercase hexadecimal value. The company, single Codex agent, single
|
||||
personal OpenAI API connection, and issue must all be named `AI Repair QA <marker>`
|
||||
(the issue uses that title). Supply their IDs with `AI_CONNECTIONS_TEST_COMPANY_ID`,
|
||||
`AI_REPAIR_TEST_CONNECTION_ID`, and `AI_REPAIR_TEST_ISSUE_ID`, and the disposable
|
||||
provider key with `AI_REPAIR_TEST_KEY`. The test verifies these boundaries before
|
||||
revoking credentials or submitting work. Delete the disposable instance and revoke
|
||||
its provider key after the test; failed tests may leave a paused task for inspection.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
# AI Connections — Storybook review milestone
|
||||
|
||||
Status: UI review approved. The app integration is implemented; see [AI Connections](../connections/AI-CONNECTIONS.md) for contracts, runtime selection, adoption, and verification.
|
||||
|
||||
## Start here
|
||||
|
||||
- [Existing Connectors page with AI accounts](http://localhost:6116/?path=/story/ai-connections-review--provider-catalog)
|
||||
- [Account details in the existing page](http://localhost:6116/?path=/story/ai-connections-review--management)
|
||||
- [Add account through the existing setup flow](http://localhost:6116/?path=/story/ai-connections-review--connect-from-existing-catalog)
|
||||
- [Existing inline task connection host](http://localhost:6116/?path=/story/ai-connections-review--inline-task-connection)
|
||||
- [Review index](http://localhost:6116/?path=/story/ai-connections-review--review-index)
|
||||
|
||||
The old `provider-catalog` URL is retained so links still work. It now renders the real `Browse` page, with AI accounts alongside GitHub and Gmail. It is not another product screen or another provider catalog.
|
||||
|
||||
Build and serve with `pnpm build-storybook` and `node scripts/serve-storybook-static.mjs --port 6116`. Alternatively run `pnpm storybook` on its normal development port.
|
||||
|
||||
The agent picker omits the personal-account inventory and its default/authorization actions. It retains the responsible-user default preview and compatible shared or already-authorized selections. **Change Personal Default** now exercises the existing account detail page; **Authorize Personal** isolates the owner-consent dialog.
|
||||
|
||||
Account details are deliberately compact: a personal-default row with a colored star/check when active, plus credential identity and reconnect/revoke actions. Agent usage and the redundant back button are removed. The preview includes the existing BreadcrumbBar for navigation. Revocation details remain in the confirmation dialog.
|
||||
|
||||
## Reading the story frames
|
||||
|
||||
Every AI review story has a **Storybook only · Review guide** above the preview. It identifies the intended app location, existing app components, proposed components, and simulated wrapper/state. Dashed boundaries mark review annotations, not product UI.
|
||||
|
||||
Agent preview headings, harness/model values, form buttons, and provider simulation controls are labeled **Storybook only**. The picker/authentication composition has its own marked component boundary. Real Connectors pages identify the new AI-only section inside the existing page; task stories mark the existing request component. These annotations live exclusively under `ui/storybook/` and do not appear in production.
|
||||
|
||||
## Existing components investigated and reused
|
||||
|
||||
The current `/:company/apps` route renders **Browse**, not the older Connections page. Its actual provider groups, account rows, search, Add account buttons, status icons, owner identities, and management menus are mounted in the stories. A small optional account-detail slot adds AI sign-in method, personal/shared identity, default, and delegation metadata to its existing rows.
|
||||
|
||||
| Existing component | Reuse in this milestone |
|
||||
| --- | --- |
|
||||
| `pages/apps/Browse.tsx` | Real Connectors list; existing provider groups and account rows. No standalone AI list. |
|
||||
| `pages/apps/AppDetail.tsx` | Existing header, naming, identities, permission loading and account status. |
|
||||
| `app-detail/IdentitiesSection.tsx` | Existing personal/company ownership display, member audience selection, and revoke confirmation dialog. |
|
||||
| `app-detail/PermissionsPanel.tsx` | Existing agent access radio cards and agent selector. Only the irrelevant tool-action section is replaced with AI account/default controls. |
|
||||
| `app-detail/AdvancedPanel.tsx` | Existing reconnect banner with an optional provider-auth callback, behind its existing permission check. |
|
||||
| `features/connections/ConnectionSetupFlow.tsx` | Existing branded setup shell, human/agent access step, navigation, cancellation and reuse flow. Provider login is composed in a credential-content slot. |
|
||||
| `features/connections/ConnectionIntentInteractionBody.tsx` | Real task card, modal, existing-account choice, completion and return-focus lifecycle. |
|
||||
| `ConnectionChoiceList` | Extracted from the existing setup flow's account-reuse rows. Both that flow and the AI agent picker render this component. |
|
||||
| `pages/apps/AppLogo.tsx` | Existing branding component in AI identity summaries; handles local and dark assets. |
|
||||
| `AdapterLoginChrome`, `AgentConfigForm`, `AgentProviderConnection` | Existing subscription card/code/input presentation extracted into shared wrappers; live lifecycle hooks remain owned by the existing hosts. |
|
||||
| `OnboardingWizard`, `ModelSourceTiles`, `CredentialModeLink` | Existing onboarding provider/method controls and API credential card remain shared. |
|
||||
|
||||
The separate `AiProviderPicker`, `AiConnectionRow`, and standalone AI management form have been removed. New AI-specific presentation is limited to agent binding selection, personal defaults/delegation, AI account controls, and controlled auth states. The design guide explains these boundaries and shows the shared picker and credential presentation.
|
||||
|
||||
## Fixture boundaries
|
||||
|
||||
`AiConnectorPages` mounts real route components against an isolated in-memory API. `AiTaskConnectionReview` mounts the real connection-request host. They use the production provider catalog, including the existing `anthropic` entry, and the `ai` / `runtime_auth` discriminator. Accounts and provider responses remain fixtures.
|
||||
|
||||
`AiConnectionsReview` covers proposed agent binding/default behavior with deterministic configuration data. Its agent/onboarding hosts are composition previews, not production route integration. The new-agent and onboarding login presentation uses the extracted existing authentication components.
|
||||
|
||||
No fixture contains credential material. Input values are cleared after submission; provider completion is simulated. No story signs in to a provider or grants real access. Personal-default resolution and delegation checks in `model.ts` are presentation validation, not server authorization.
|
||||
|
||||
## Review and verification
|
||||
|
||||
Review the Connectors list first, then open an account, add one, reconnect, reuse it in a task, and exercise the AI binding picker. Theme and viewport controls cover desktop/narrow and light/dark. Keyboard coverage includes the shared chooser buttons, dialogs, and return focus after cancellation/completion. Harness/model values are asserted unchanged by connection selection.
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/ui typecheck
|
||||
pnpm check:token-gates
|
||||
pnpm --filter @paperclipai/ui exec vitest run src/pages/apps/Browse.test.tsx src/pages/apps/AppDetail.test.tsx src/pages/apps/AppsConnect.test.tsx src/features/connections/ConnectionIntentInteractionBody.test.tsx src/components/ai-connections src/components/AdapterLoginChrome.test.tsx src/components/OnboardingWizard.adapters.test.tsx src/components/OnboardingWizard.test.tsx
|
||||
pnpm build-storybook
|
||||
pnpm exec playwright test --config tests/ai-connections-review/playwright.config.ts
|
||||
```
|
||||
|
||||
Verified: 277 focused Vitest checks, 53 browser checks across 48 stories, UI typecheck, token gates, and Storybook build. Desktop and narrow screenshots were inspected in light/dark themes.
|
||||
|
||||
Browser checks load every AI review story, await its interaction assertions, reject rendering/play errors, verify review-index links, test keyboard selection, and capture light/dark layouts at desktop and narrow widths while checking overflow. Screenshots remain ignored local test artifacts.
|
||||
|
||||
The counts above record the original UI review. Integration verification is recorded in the implementation handoff; live provider verification requires valid accounts and a supported sign-in environment.
|
||||
|
||||
## Agreed implementation after review
|
||||
|
||||
- Extend existing applications/connections/grants/installations/delegations with an AI purpose and runtime-auth transport. Reuse encrypted secret storage and company access checks; keep tool/channel execution separate.
|
||||
- Add typed agent bindings and personal defaults keyed by company, user, provider, and sign-in method. First successful personal connection becomes default only when none exists. Additional defaults require explicit selection; revocation never chooses a replacement.
|
||||
- Resolve personal defaults from the run's responsible user. Shared and dedicated personal bindings are explicit. Only the owner can authorize their personal account across responsible users.
|
||||
- Resolve the chosen grant before local, sandbox, native-runner, and test execution. Prevent inherited credentials or cached homes from overriding it; partition session/auth reuse by grant identity, preserve refresh ownership, and report actionable missing-access blockers.
|
||||
- Keep provider, method, harness, and model routing fixed during connection selection. Start with Claude, OpenAI/ChatGPT, OpenRouter, and Grok/xAI, using only supported existing authentication methods and harness integrations.
|
||||
- Preserve legacy execution until adoption. Index only credentials with reliable ownership; never infer ownership from account-home paths. Test and explicitly save the replacement binding; do not restore legacy fallback after adoption.
|
||||
- Schema/API, runtime integration, adoption, and production UI wiring are implemented after approval. There is no separate AI Connections feature flag.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
packages:
|
||||
- '.'
|
||||
|
||||
# The SDK ships generated protobuf code; its optional install script is not needed.
|
||||
allowBuilds:
|
||||
protobufjs: false
|
||||
|
|
@ -213,12 +213,18 @@ describe("ensureLocalPluginBuilt", () => {
|
|||
expect(execStub).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bootstraps standalone bundled plugins before building them", async () => {
|
||||
it.each([
|
||||
undefined,
|
||||
"allowBuilds:\n protobufjs: false\n",
|
||||
"packages:\n - ../untrusted\ndangerouslyAllowAllBuilds: true\n",
|
||||
])("bootstraps standalone plugins without trusting workspace policy: %s", async (localPolicy) => {
|
||||
const fixture = await createBundledPluginFixture("standalone", { rootDir: standaloneRepoPluginRoot });
|
||||
cleanupPaths.add(fixture.packageRoot);
|
||||
|
||||
if (localPolicy) await writeFile(path.join(fixture.packageRoot, "pnpm-workspace.yaml"), localPolicy);
|
||||
const installArgs = ["install", "--ignore-workspace", ...(localPolicy ? ["--ignore-scripts"] : []), "--no-lockfile"];
|
||||
const execStub = vi.fn(async (_file: string, args: readonly string[]) => {
|
||||
if (args.join(" ") === "install --ignore-workspace --no-lockfile") {
|
||||
if (args.join(" ") === installArgs.join(" ")) {
|
||||
await mkdir(path.join(fixture.packageRoot, "node_modules", "@paperclipai", "plugin-sdk"), { recursive: true });
|
||||
}
|
||||
if (args.join(" ") === "build") {
|
||||
|
|
@ -239,7 +245,7 @@ describe("ensureLocalPluginBuilt", () => {
|
|||
expect(execStub).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"pnpm",
|
||||
["install", "--ignore-workspace", "--no-lockfile"],
|
||||
installArgs,
|
||||
{ cwd: fixture.packageRoot, timeout: 120_000 },
|
||||
);
|
||||
expect(execStub).toHaveBeenNthCalledWith(
|
||||
|
|
|
|||
|
|
@ -793,9 +793,11 @@ function buildStandaloneBundledPluginInstallArgs(
|
|||
packageRoot: string,
|
||||
): string[] {
|
||||
const packageLockfilePath = path.join(packageRoot, "pnpm-lock.yaml");
|
||||
return existsSync(packageLockfilePath)
|
||||
? ["install", "--ignore-workspace", "--frozen-lockfile"]
|
||||
: ["install", "--ignore-workspace", "--no-lockfile"];
|
||||
// Never let plugin-supplied workspace settings broaden dependency resolution
|
||||
// or script execution. When a plugin declares a local install policy, disable
|
||||
// dependency lifecycle scripts instead of loading that workspace configuration.
|
||||
const scriptArgs = existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) ? ["--ignore-scripts"] : [];
|
||||
return ["install", "--ignore-workspace", ...scriptArgs, existsSync(packageLockfilePath) ? "--frozen-lockfile" : "--no-lockfile"];
|
||||
}
|
||||
|
||||
function buildStandaloneBundledPluginInstallCommand(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Opt in against an isolated test drive; never seed or modify a production account.
|
||||
const companyId = process.env.AI_CONNECTIONS_TEST_COMPANY_ID;
|
||||
test.skip(!companyId, "Provide the isolated test-drive company ID");
|
||||
let prefix: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const response = await request.get(`/api/companies/${companyId}`);
|
||||
expect(response.ok()).toBe(true);
|
||||
prefix = (await response.json()).issuePrefix;
|
||||
});
|
||||
|
||||
test("existing Connections lists AI providers and keeps account management compact", async ({ page, request }, testInfo) => {
|
||||
await page.goto(`/${prefix}/apps`);
|
||||
for (const provider of ["Anthropic", "OpenAI", "OpenRouter", "Grok"]) {
|
||||
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");
|
||||
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: "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();
|
||||
await expect(page.getByRole("heading", { name: "Which agents can use this connection?" })).toBeVisible();
|
||||
await expect(page.getByText("Authorized use for other users’ tasks", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("combobox", { name: "Authorize an agent" })).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("account-details.png"), fullPage: true });
|
||||
await page.getByRole("button", { name: "Reconnect", exact: true }).last().click();
|
||||
await expect(page.getByText("Step 1 of 1", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Connection name")).toBeDisabled();
|
||||
await expect(page.getByRole("heading", { name: "Which humans can use this credential?" })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).last().click();
|
||||
});
|
||||
|
||||
test("rejected API credentials do not create a connection, and cancellation returns to Connections", async ({ page, request }, testInfo) => {
|
||||
const before = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json();
|
||||
await page.goto(`/${prefix}/apps/connect?source=openrouter&method=ai-api_key`);
|
||||
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
|
||||
await page.getByLabel("Connection name").fill("Rejected browser test account");
|
||||
await page.getByRole("textbox", { name: "API key", exact: true }).fill("invalid-ai-connection-browser-test");
|
||||
await page.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect(page.getByRole("alert")).toContainText(/rejected|Could not verify|could not verify/);
|
||||
await expect(page.getByRole("textbox", { name: "API key", exact: true })).toHaveValue("");
|
||||
const after = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json();
|
||||
expect(after.connections.map((entry: { id: string }) => entry.id).sort()).toEqual(before.connections.map((entry: { id: string }) => entry.id).sort());
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).last().click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${prefix}/apps$`));
|
||||
});
|
||||
|
||||
test("legacy adoption and inline account cancellation preserve the agent configuration", async ({ page, request }, testInfo) => {
|
||||
const agents = await (await request.get(`/api/companies/${companyId}/agents`)).json();
|
||||
const agent = agents.find((entry: { adapterType: string; runtimeConfig: { aiConnection?: unknown } }) => ["claude_local", "codex_local", "grok_local"].includes(entry.adapterType) && !entry.runtimeConfig.aiConnection);
|
||||
expect(agent, "The drive should include a legacy agent for adoption review").toBeTruthy();
|
||||
await page.goto(`/${prefix}/agents/${agent.urlKey ?? agent.id}/runtime`);
|
||||
await page.getByRole("button", { name: "Choose a managed connection", exact: true }).click();
|
||||
await expect(page.getByRole("region", { name: "AI connection", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Your personal accounts", { exact: true })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Connect another account", exact: true }).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Connect another account", exact: true })).toBeFocused();
|
||||
await page.screenshot({ path: testInfo.outputPath("agent-ai-connection.png"), fullPage: true });
|
||||
await page.getByRole("button", { name: /Responsible user’s connection/ }).click();
|
||||
await expect(page.getByRole("dialog")).toContainText(`Adopt Connections for ${agent.name}`);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
const after = await (await request.get(`/api/agents/${agent.id}`)).json();
|
||||
expect(after.adapterType).toBe(agent.adapterType);
|
||||
expect(after.adapterConfig).toEqual(agent.adapterConfig);
|
||||
expect(after.runtimeConfig).toEqual(agent.runtimeConfig);
|
||||
});
|
||||
|
||||
|
||||
test("connection setup waits for provider details before enabling Continue", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route(`**/api/companies/${companyId}/tools/gallery`, async route => {
|
||||
await held;
|
||||
await route.continue();
|
||||
});
|
||||
await page.goto(`/${prefix}/apps/connect?source=openrouter&method=ai-api_key`);
|
||||
const next = page.getByRole("button", { name: /^(Save and continue|Continue)$/ });
|
||||
await expect(next).toBeDisabled();
|
||||
release();
|
||||
await next.click();
|
||||
await expect(page.getByLabel("Connection name")).toBeVisible();
|
||||
await expect(page).toHaveURL(/stage=setup/);
|
||||
});
|
||||
|
||||
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 => {
|
||||
expect(new URL(route.request().url()).searchParams.get("provider")).toBe("openrouter");
|
||||
await route.fulfill({ json: [{ id: "openrouter/anthropic/claude-sonnet-4.5", label: "Claude Sonnet 4.5" }] });
|
||||
});
|
||||
await page.route(`**/api/companies/${companyId}/adapters/opencode_local/test-environment`, async route => {
|
||||
tested = route.request().postDataJSON();
|
||||
await route.fulfill({ json: { status: "pass", checks: [], testedAt: new Date().toISOString() } });
|
||||
});
|
||||
await page.goto(`/${prefix}/agents/new?name=OpenRouter+binding+regression&adapterType=opencode_local`);
|
||||
await expect(page.getByText("Existing authentication — not managed by Connections", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Responsible user’s connection/ })).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByRole("button", { name: "Select model (required)", exact: true }).click();
|
||||
await page.getByRole("button", { name: "anthropic/claude-sonnet-4.5", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Run test", exact: true }).click();
|
||||
await expect.poll(() => tested).toBeTruthy();
|
||||
expect(tested?.aiConnection).toEqual({ provider: "openrouter", method: "api_key", mode: "responsible_user" });
|
||||
expect(tested?.adapterConfig?.model).toBe("openrouter/anthropic/claude-sonnet-4.5");
|
||||
});
|
||||
|
||||
test("ordinary Anthropic setup keeps the existing tool method available", async ({ page }) => {
|
||||
await page.goto(`/${prefix}/apps/connect?source=anthropic`);
|
||||
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
|
||||
await expect(page.getByText("How do you want to connect?", { exact: true })).toBeVisible();
|
||||
await page.getByRole("radio", { name: "Use an API key", exact: true }).click();
|
||||
await expect(page.getByLabel("Your Anthropic key", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("radiogroup", { name: "Connect your model provider" })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).first().click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${prefix}/apps$`));
|
||||
});
|
||||
|
||||
test("explicit OpenAI API method survives continuing and reloading", async ({ page }) => {
|
||||
await page.goto(`/${prefix}/apps/connect?source=openai&method=ai-api_key`);
|
||||
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
|
||||
await expect(page).toHaveURL(/method=ai-api_key/);
|
||||
await page.reload();
|
||||
await page.getByRole("radio", { name: /OpenAI/ }).click();
|
||||
await expect(page.getByLabel("API key", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/CODEX_HOME=/)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).first().click();
|
||||
});
|
||||
|
||||
for (const [provider, label] of [["anthropic", "Claude"], ["openai", "OpenAI"]]) {
|
||||
test(`Connections reuses the agent provider step for ${label}`, async ({ page }, testInfo) => {
|
||||
await page.goto(`/${prefix}/apps/connect?source=${provider}&method=ai-subscription`);
|
||||
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
|
||||
await expect(page.getByRole("radiogroup", { name: "Connect your model provider" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Connect for tool access instead" })).toHaveCount(0);
|
||||
await page.getByRole("radio", { name: new RegExp(label) }).click();
|
||||
if (provider === "anthropic") {
|
||||
await expect(page.getByText(/Connect uses your local/)).toBeVisible();
|
||||
await expect(page.getByText("claude auth login", { exact: true })).toBeVisible();
|
||||
} else {
|
||||
await expect(page.getByText(/Your existing terminal login stays separate/)).toBeVisible();
|
||||
const command = page.getByText(/^CODEX_HOME=.* codex login$/);
|
||||
await expect(command).toBeVisible();
|
||||
const preparedCommand = await command.textContent();
|
||||
await page.reload();
|
||||
await page.getByRole("radio", { name: new RegExp(label) }).click();
|
||||
await expect(command).toHaveText(preparedCommand!);
|
||||
}
|
||||
await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled();
|
||||
// Let the shared tile-collapse and card-enter animations settle for visual review.
|
||||
await page.waitForTimeout(1000);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${provider}-shared-provider-step.png`), fullPage: true });
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Use API key instead", exact: true }).click();
|
||||
await page.getByRole("radio", { name: new RegExp(label) }).click();
|
||||
await expect(page.getByLabel("API key", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Provide your .* API key to connect/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).first().click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${prefix}/apps$`));
|
||||
});
|
||||
}
|
||||
|
||||
// Exercise the real onboarding login controllers; only provider/server replies
|
||||
// are simulated. No credentials, sandbox leases or accounts are created here.
|
||||
for (const [provider, label, adapter] of [["anthropic", "Claude", "claude_local"], ["openai", "OpenAI", "codex_local"]]) {
|
||||
test(`Connections uses onboarding browser sign-in for ${label}`, async ({ page }, testInfo) => {
|
||||
const environmentId = "11111111-1111-4111-8111-111111111111";
|
||||
const sessionId = "22222222-2222-4222-8222-222222222222";
|
||||
const base = `/api/companies/${companyId}`;
|
||||
const sessions = provider === "anthropic" ? `${base}/setup-token-login-sessions` : `${base}/adapters/${adapter}/login-sessions`;
|
||||
let starts = 0;
|
||||
let cancels = 0;
|
||||
let intent: Record<string, unknown> | undefined;
|
||||
const session = () => ({ sessionId, environmentId, adapterType: adapter, status: provider === "anthropic" ? "awaiting_code" : "awaiting_user", expiresAt: new Date(Date.now() + 300000).toISOString(), aiConnection: intent, prompt: provider === "anthropic" ? { authorizationUrl: "https://provider.example/authorize" } : { url: "https://provider.example/authorize", code: "ABCD-EFGH" } });
|
||||
await page.route(`**${base}/environments`, route => route.fulfill({ json: [{ id: environmentId, name: "Browser sign-in test sandbox", driver: "sandbox", status: "active", config: { provider: "browser-test" } }] }));
|
||||
await page.route(`**${base}/environments/capabilities`, route => route.fulfill({ json: { sandboxProviders: { "browser-test": { supportsLoginPty: true } } } }));
|
||||
await page.route(`**${sessions}**`, async route => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path.endsWith("/active")) return route.fulfill(starts ? { json: session() } : { status: 404, json: { error: "Not found" } });
|
||||
if (path.endsWith("/cancel")) { cancels++; return route.fulfill({ json: {} }); }
|
||||
if (path.endsWith("/prompt")) return route.fulfill({ json: { authorizationUrl: "https://provider.example/authorize" } });
|
||||
if (path === sessions && route.request().method() === "POST") {
|
||||
starts++;
|
||||
const payload = route.request().postDataJSON();
|
||||
expect(payload.environmentId).toBe(environmentId);
|
||||
expect(payload.aiConnection.provider).toBe(provider);
|
||||
intent = payload.aiConnection;
|
||||
}
|
||||
return route.fulfill({ json: session() });
|
||||
});
|
||||
await page.addInitScript(() => { window.open = (url) => { (window as unknown as { loginDestination: string }).loginDestination = String(url); return null; }; });
|
||||
await page.goto(`/${prefix}/apps/connect?source=${provider}&method=ai-subscription`);
|
||||
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
|
||||
await page.getByRole("radio", { name: new RegExp(label) }).click();
|
||||
const signIn = page.getByRole("button", { name: `Sign in to ${label}`, exact: true });
|
||||
await expect(signIn).toBeEnabled();
|
||||
await expect(page.getByText(/login on this machine/)).toHaveCount(0);
|
||||
if (provider === "anthropic") await expect(page.locator('input[type="password"]')).toBeVisible();
|
||||
else await expect(page.getByText("ABCD-EFGH", { exact: true })).toBeVisible();
|
||||
await signIn.click();
|
||||
expect(await page.evaluate(() => (window as unknown as { loginDestination: string }).loginDestination)).toBe("https://provider.example/authorize");
|
||||
await expect(page.getByRole("button", { name: "Waiting for code", exact: true })).toBeDisabled();
|
||||
// The shared footer label animates; capture after it has settled.
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${provider}-onboarding-browser-login.png`), fullPage: true });
|
||||
if (provider === "anthropic") {
|
||||
const submitted = page.waitForRequest(request => request.url().endsWith(`${sessionId}/code`) && request.method() === "POST");
|
||||
await page.locator('input[type="password"]').fill("storybook-fixture-code");
|
||||
await page.locator('input[type="password"]').press("Enter");
|
||||
await submitted;
|
||||
await expect(page.getByRole("button", { name: "Connecting", exact: true })).toBeDisabled();
|
||||
}
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await page.getByRole("radio", { name: new RegExp(label) }).click();
|
||||
await expect(page.getByRole("button", { name: `Sign in to ${label}`, exact: true })).toBeEnabled();
|
||||
expect(starts).toBe(1);
|
||||
expect(cancels).toBe(0);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Explicitly opt in with a disposable task/account and a real provider key.
|
||||
// All writes use the UI. API reads only verify identity and execution outcomes.
|
||||
const disposableMarker = process.env.AI_REPAIR_TEST_DISPOSABLE_MARKER;
|
||||
const destructiveOptIn = process.env.AI_REPAIR_TEST_ALLOW_DESTRUCTIVE === "1";
|
||||
const companyId = process.env.AI_CONNECTIONS_TEST_COMPANY_ID;
|
||||
const issueId = process.env.AI_REPAIR_TEST_ISSUE_ID;
|
||||
const connectionId = process.env.AI_REPAIR_TEST_CONNECTION_ID;
|
||||
const providerKey = process.env.AI_REPAIR_TEST_KEY;
|
||||
test.use({ trace: "off", video: "off" });
|
||||
test.skip(!destructiveOptIn || !disposableMarker || !companyId || !issueId || !connectionId || !providerKey, "Live repair requires explicit disposable fixtures and a provider key");
|
||||
|
||||
test("repair the selected AI account inside the task and continue without another message", async ({ page, request }, testInfo) => {
|
||||
test.setTimeout(240_000);
|
||||
if (process.env.AI_REPAIR_TEST_NARROW === "1") await page.setViewportSize({ width: 390, height: 844 });
|
||||
// Fail before any mutation unless every target belongs to the same explicitly
|
||||
// marked disposable fixture. Never run this scenario against a remote host.
|
||||
const origin = new URL(testInfo.project.use.baseURL!);
|
||||
expect(origin.protocol).toBe("http:");
|
||||
expect(["127.0.0.1", "[::1]"]).toContain(origin.hostname);
|
||||
expect(disposableMarker).toMatch(/^[a-f0-9]{32}$/);
|
||||
const fixtureName = `AI Repair QA ${disposableMarker}`;
|
||||
const health = await (await request.get("/api/health")).json();
|
||||
expect(health.deploymentMode).toBe("local_trusted");
|
||||
const companies = await (await request.get("/api/companies")).json();
|
||||
const company = companies.find((company: { id: string }) => company.id === companyId);
|
||||
expect(company).toMatchObject({ id: companyId, name: fixtureName });
|
||||
const prefix = company.issuePrefix;
|
||||
const taskBefore = await (await request.get(`/api/issues/${issueId}`)).json();
|
||||
const agentBefore = await (await request.get(`/api/agents/${taskBefore.assigneeAgentId}`)).json();
|
||||
expect(taskBefore).toMatchObject({ companyId, title: fixtureName });
|
||||
expect(agentBefore).toMatchObject({ companyId, name: fixtureName, adapterType: "codex_local" });
|
||||
const agents = await (await request.get(`/api/companies/${companyId}/agents`)).json();
|
||||
expect(agents.map((agent: { id: string }) => agent.id)).toEqual([agentBefore.id]);
|
||||
const list = async () => (await (await request.get(`/api/companies/${companyId}/ai-connections`)).json()).connections;
|
||||
const before = await list();
|
||||
const accountBefore = before.find((connection: { id: string }) => connection.id === connectionId);
|
||||
expect(before).toHaveLength(1);
|
||||
expect(accountBefore).toMatchObject({ companyId, name: fixtureName, provider: "openai", method: "api_key", ownership: "personal" });
|
||||
expect(accountBefore.isDefault).toBe(true);
|
||||
expect(["connected", "revoked"]).toContain(accountBefore.status);
|
||||
|
||||
await page.goto(`/${prefix}/apps/${connectionId}/permissions`);
|
||||
if (accountBefore.status === "connected") {
|
||||
await page.getByRole("button", { name: "Revoke identity", exact: true }).click();
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: "Revoke identity", exact: true }).click();
|
||||
}
|
||||
await expect(page.locator("header").getByText("Revoked", { exact: true })).toBeVisible();
|
||||
await page.goto(`/${prefix}/issues/${issueId}`);
|
||||
let proof = `QA-IN-CARD-REPAIR-${Date.now()}: 1147`;
|
||||
const pending = (await (await request.get(`/api/issues/${issueId}/interactions`)).json()).some((interaction: {kind: string; status: string; payload: {purpose?: string}}) => interaction.kind === "connection_intent" && interaction.status === "pending" && interaction.payload.purpose === "ai");
|
||||
if (pending) {
|
||||
const comments = await (await request.get(`/api/issues/${issueId}/comments`)).json();
|
||||
proof = comments.map((comment: {body: string}) => comment.body.match(/QA-IN-CARD-REPAIR-\d+: 1147/)?.[0]).filter(Boolean).at(-1);
|
||||
expect(proof).toBeTruthy();
|
||||
} else {
|
||||
await page.getByRole("textbox", { name: "editable markdown" }).fill(`Inline repair acceptance: calculate 31 * 37. Post exactly ${proof}, then mark Done. This first attempt should block on my revoked default; I will reconnect it inside this task. Do not change configuration, create subtasks, or modify files.`);
|
||||
await page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
}
|
||||
const fix = page.getByRole("button", { name: "Fix connection", exact: true });
|
||||
await expect(fix).toBeVisible({ timeout: 60_000 });
|
||||
await fix.click();
|
||||
const inline = page.getByTestId("ai-connection-inline-repair");
|
||||
await expect(inline.getByLabel("Connection name")).toHaveValue(accountBefore.name);
|
||||
await expect(inline.getByLabel("Connection name")).toBeDisabled();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await inline.getByRole("button", { name: /^(Back|Cancel)$/ }).click();
|
||||
await expect(page.getByTestId("ai-connection-inline-repair")).toHaveCount(0);
|
||||
await expect(page.getByTestId("connection-intent-focus-target").filter({ has: fix })).toBeFocused();
|
||||
await fix.click();
|
||||
await inline.getByRole("radio", { name: "OpenAI API", exact: true }).click();
|
||||
const keyField = inline.getByPlaceholder("Enter API key here");
|
||||
await expect(keyField).toBeVisible();
|
||||
await inline.screenshot({ path: testInfo.outputPath("inline-repair-before.png") });
|
||||
// Never include credentials in a failed action's error/trace output.
|
||||
try { await keyField.fill(providerKey!); } catch { throw new Error("Could not fill the private credential field"); }
|
||||
await inline.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect(page.getByTestId("ai-connection-inline-repair")).toHaveCount(0, { timeout: 60_000 });
|
||||
await expect(page.getByText(proof, { exact: true })).toBeVisible({ timeout: 120_000 });
|
||||
await expect.poll(async () => (await (await request.get(`/api/issues/${issueId}`)).json()).status).toBe("done");
|
||||
const after = await list();
|
||||
expect(after).toHaveLength(before.length);
|
||||
expect(after.find((connection: { id: string }) => connection.id === connectionId)).toMatchObject({ id: connectionId, grantId: accountBefore.grantId, isDefault: true, status: "connected" });
|
||||
const agentAfter = await (await request.get(`/api/agents/${taskBefore.assigneeAgentId}`)).json();
|
||||
expect(agentAfter.adapterType).toBe(agentBefore.adapterType);
|
||||
expect(agentAfter.adapterConfig).toEqual(agentBefore.adapterConfig);
|
||||
expect(agentAfter.runtimeConfig).toEqual(agentBefore.runtimeConfig);
|
||||
await page.screenshot({ path: testInfo.outputPath("inline-repair-completed.png"), fullPage: true });
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testIgnore: process.env.AI_REPAIR_TEST_ALLOW_DESTRUCTIVE === "1" ? [] : ["**/*.live.spec.ts"],
|
||||
outputDir: "./test-results",
|
||||
timeout: 45_000,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: process.env.AI_CONNECTIONS_TEST_URL ?? "http://127.0.0.1:3100",
|
||||
browserName: "chromium",
|
||||
reducedMotion: "reduce",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
outputDir: "./test-results",
|
||||
timeout: 30_000,
|
||||
workers: 2,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:6116",
|
||||
browserName: "chromium",
|
||||
reducedMotion: "reduce",
|
||||
},
|
||||
webServer: {
|
||||
command: "node ../../scripts/serve-storybook-static.mjs --port 6116",
|
||||
url: "http://localhost:6116/index.json",
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const index = JSON.parse(
|
||||
readFileSync(
|
||||
new URL("../../ui/storybook-static/index.json", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const stories = Object.keys(index.entries).filter((id) =>
|
||||
id.startsWith("ai-connections-review--"),
|
||||
);
|
||||
|
||||
for (const id of stories) {
|
||||
test(id, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (
|
||||
message.type() === "error" &&
|
||||
!message.text().startsWith("Failed to load resource")
|
||||
)
|
||||
errors.push(message.text());
|
||||
});
|
||||
await page.route("**/*", (route) =>
|
||||
new URL(route.request().url()).hostname === "localhost"
|
||||
? route.continue()
|
||||
: route.abort(),
|
||||
);
|
||||
await page.goto(`/iframe.html?id=${id}&viewMode=story`);
|
||||
// Completing follows the awaited play function, including its assertions.
|
||||
await page.waitForFunction(() => {
|
||||
const preview = (
|
||||
window as unknown as {
|
||||
__STORYBOOK_PREVIEW__?: { currentRender?: { phase: string } };
|
||||
}
|
||||
).__STORYBOOK_PREVIEW__;
|
||||
return ["completing", "completed", "finished", "errored"].includes(
|
||||
preview?.currentRender?.phase ?? "",
|
||||
);
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Boolean(
|
||||
document.querySelector("#storybook-root")?.textContent ||
|
||||
document.querySelector('[role="dialog"]')?.textContent,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
expect(errors, `Story/play errors for ${id}`).toEqual([]);
|
||||
await expect(page.getByTestId("ai-review-frame")).toContainText("Already in the app");
|
||||
await expect(page.getByTestId("ai-review-frame")).toContainText("Storybook simulation");
|
||||
if (id.endsWith("responsible-user")) {
|
||||
await expect(page.getByTestId("ai-review-preview")).toContainText("Example page context · Storybook only");
|
||||
await expect(page.getByTestId("ai-component-boundary")).toContainText("App component: AiConnectionPicker");
|
||||
await expect(page.getByText("Your personal accounts", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Make default|Authorize for/ })).toHaveCount(0);
|
||||
await expect(page.getByText("For you: My Claude subscription", { exact: true })).toBeVisible();
|
||||
}
|
||||
if (id.endsWith("review-index")) {
|
||||
const links = await page
|
||||
.locator("#storybook-root a")
|
||||
.evaluateAll((anchors) =>
|
||||
anchors.map((anchor) => (anchor as HTMLAnchorElement).href),
|
||||
);
|
||||
for (const href of links) {
|
||||
const url = new URL(href);
|
||||
expect(url.pathname).toBe("/");
|
||||
expect(
|
||||
index.entries[url.searchParams.get("path")!.replace("/story/", "")],
|
||||
).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const width of [390, 1200]) {
|
||||
test(`layout ${theme} ${width}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 960 });
|
||||
for (const story of [
|
||||
"responsible-user",
|
||||
"claude-subscription",
|
||||
"identity-matrix",
|
||||
"management",
|
||||
]) {
|
||||
await page.goto(
|
||||
`/iframe.html?id=ai-connections-review--${story}&viewMode=story&globals=theme:${theme}`,
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Boolean(
|
||||
document.querySelector("#storybook-root")?.textContent ||
|
||||
document.querySelector('[role="dialog"]')?.textContent,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
if (story === "management") await expect(page.getByLabel("AI account settings")).toBeVisible();
|
||||
if (story === "identity-matrix") await expect(page.getByRole("button", { name: "Add account Anthropic" })).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`${story}-${theme}-${width}.png`),
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("shared connection chooser keyboard navigation preserves runtime", async ({ page }) => {
|
||||
await page.goto(
|
||||
"/iframe.html?id=ai-connections-review--responsible-user&viewMode=story",
|
||||
);
|
||||
const first = page.getByRole("button", { name: "Responsible user’s connection", exact: true });
|
||||
await first.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Engineering Claude", exact: true }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Engineering Claude", exact: true }),
|
||||
).toBeFocused();
|
||||
await expect(page.getByTestId("ai-harness")).toHaveText("Claude Code");
|
||||
await expect(page.getByTestId("ai-model")).toHaveText(
|
||||
"Configured Claude model",
|
||||
);
|
||||
});
|
||||
|
||||
// Keep upstream's real saved-account and login workflow in this integration's
|
||||
// review gate as well as the AI-specific component stories above.
|
||||
for (const id of Object.keys(index.entries).filter((entry) => entry.startsWith("onboarding-saved-connections--"))) {
|
||||
test(`upstream ${id}`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.goto(`/iframe.html?id=${id}&viewMode=story`);
|
||||
await page.waitForFunction(() => {
|
||||
const preview = (window as unknown as { __STORYBOOK_PREVIEW__?: { currentRender?: { phase: string } } }).__STORYBOOK_PREVIEW__;
|
||||
return ["completed", "finished", "errored"].includes(preview?.currentRender?.phase ?? "");
|
||||
});
|
||||
await expect(page.locator("#storybook-root")).not.toBeEmpty();
|
||||
expect(errors).toEqual([]);
|
||||
expect(await page.evaluate(() => (window as unknown as { __STORYBOOK_PREVIEW__?: { currentRender?: { phase: string } } }).__STORYBOOK_PREVIEW__?.currentRender?.phase)).not.toBe("errored");
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { mockOnboardingLocalAiConnection } from "./helpers/onboarding-ai-connection";
|
||||
import {
|
||||
expectLandsOnFirstTaskWithoutDashboardBounce,
|
||||
instrumentNavLog,
|
||||
|
|
@ -21,11 +22,12 @@ import {
|
|||
const FIRST_TASK_TITLE = "Paperclip onboarding";
|
||||
|
||||
/**
|
||||
* Intercept the two side-effecting calls the wizard makes so no real CLI check
|
||||
* Intercept authentication, environment checks, and hiring so no real CLI check
|
||||
* runs and no real agent process spawns (the hire still happens server-side
|
||||
* with an inert http adapter).
|
||||
*/
|
||||
async function installLaunchIntercepts(page: Page, baseURL?: string) {
|
||||
await mockOnboardingLocalAiConnection(page);
|
||||
await page.route("**/test-environment", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
|
|
@ -93,9 +95,8 @@ async function runOnboardingWizard(page: Page, companyName: string) {
|
|||
await source.click();
|
||||
|
||||
// "Connect", not "Next": this step's button starts the sign-in where there
|
||||
// is one to start, so it is named for what it does. Here there is none —
|
||||
// this instance has no sandbox environment, so the step has no login to
|
||||
// offer and Connect goes straight to the hire.
|
||||
// is one to start, so it is named for what it does. This test simulates
|
||||
// successful local account connection before the environment check and hire.
|
||||
//
|
||||
// Waited on for enabled rather than for visible: it is already on screen,
|
||||
// disabled, and clicking a disabled button raises nothing and does nothing.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
/** First-task tests exercise the real wizard and hire/task APIs with an inert
|
||||
* adapter. Simulate successful provider authentication without using host auth. */
|
||||
export async function mockOnboardingLocalAiConnection(page: Page) {
|
||||
const connectionId = randomUUID();
|
||||
const grantId = randomUUID();
|
||||
await page.route("**/ai-connections/local", async (route) => {
|
||||
expect(route.request().method()).toBe("POST");
|
||||
expect(route.request().postDataJSON()).toMatchObject({ method: "subscription", ownership: "personal" });
|
||||
await route.fulfill({ json: { connectionId, grantId } });
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { mockOnboardingLocalAiConnection } from "./helpers/onboarding-ai-connection";
|
||||
|
||||
/**
|
||||
* E2E: Onboarding wizard flow (NUX Phase 2 expanded wizard).
|
||||
|
|
@ -389,15 +390,19 @@ test.describe("Onboarding wizard", () => {
|
|||
// route, and keeping the pre-Connect part of this test the same as the
|
||||
// ordinary sign-in test above.
|
||||
let sessionStarted = false;
|
||||
let aiConnection: Record<string, unknown> | undefined;
|
||||
await page.route("**/setup-token-login-sessions", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
startCalls += 1;
|
||||
sessionStarted = true;
|
||||
aiConnection = route.request().postDataJSON().aiConnection;
|
||||
}
|
||||
return route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionId: SESSION_ID,
|
||||
environmentId: FAKE_SANDBOX_ENVIRONMENT_ID,
|
||||
aiConnection,
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
}),
|
||||
|
|
@ -418,6 +423,8 @@ test.describe("Onboarding wizard", () => {
|
|||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionId: SESSION_ID,
|
||||
environmentId: FAKE_SANDBOX_ENVIRONMENT_ID,
|
||||
aiConnection,
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
}),
|
||||
|
|
@ -438,6 +445,8 @@ test.describe("Onboarding wizard", () => {
|
|||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionId: SESSION_ID,
|
||||
environmentId: FAKE_SANDBOX_ENVIRONMENT_ID,
|
||||
aiConnection,
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
panelMode: "submitted_browser_code",
|
||||
|
|
@ -512,14 +521,12 @@ test.describe("Onboarding wizard", () => {
|
|||
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("connect step blocks the hire when the environment probe fails and no sign-in is needed", async ({
|
||||
test("connect step blocks the hire when the environment probe fails after account connection", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The other half of what the test above used to cover. The two claims are
|
||||
// different situations now: there, an absent credential makes Connect start
|
||||
// a sign-in; here there is no sandbox to sign in against — this throwaway
|
||||
// instance only auto-creates the local environment, and nothing below adds
|
||||
// one — so Connect goes straight to the probe, and the probe is the gate.
|
||||
// A successful local account connection still requires a passing
|
||||
// environment probe before the wizard may hire the agent.
|
||||
await mockOnboardingLocalAiConnection(page);
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
import { mockOnboardingLocalAiConnection } from "./helpers/onboarding-ai-connection";
|
||||
import {
|
||||
expectLandsOnFirstTaskWithoutDashboardBounce,
|
||||
instrumentNavLog,
|
||||
|
|
@ -35,6 +36,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
|
|||
const screenshotDir = "test-results/planning-mode";
|
||||
|
||||
await instrumentNavLog(page);
|
||||
await mockOnboardingLocalAiConnection(page);
|
||||
|
||||
await page.route("**/test-environment", (route) =>
|
||||
route.fulfill({
|
||||
|
|
@ -92,8 +94,8 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
|
|||
// The connect step arrives with no source selected — the tile row is a
|
||||
// question, not a confirmation — so its CTA stays disabled until one is
|
||||
// pressed. It reads "Connect", not "Next": the button starts the sign-in
|
||||
// where there is one to start. This instance has no sandbox environment, so
|
||||
// there is none, and Connect goes straight to the hire.
|
||||
// where there is one to start. The test simulates successful local account
|
||||
// connection, then exercises the real first-task creation flow.
|
||||
//
|
||||
// Waited on for enabled rather than visible: it is already on screen, and
|
||||
// clicking a disabled button raises nothing and does nothing.
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ export const agentsApi = {
|
|||
type: string,
|
||||
data: {
|
||||
adapterConfig: Record<string, unknown>;
|
||||
aiConnection?: import("@paperclipai/shared").AiConnectionBinding;
|
||||
agentId?: string;
|
||||
testCredentials?: Record<string, string>;
|
||||
environmentId?: string | null;
|
||||
|
|
@ -274,7 +275,7 @@ export const agentsApi = {
|
|||
startAdapterAuthLogin: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
data: { environmentId: string; ttlSeconds?: number },
|
||||
data: { environmentId: string; ttlSeconds?: number; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent },
|
||||
) =>
|
||||
api.post<AdapterAuthSessionResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions`,
|
||||
|
|
@ -317,7 +318,7 @@ export const agentsApi = {
|
|||
),
|
||||
startClaudeSetupTokenLogin: (
|
||||
companyId: string,
|
||||
data: { environmentId: string; overwrite?: ClaudeSetupTokenOverwrite },
|
||||
data: { environmentId: string; overwrite?: ClaudeSetupTokenOverwrite; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent },
|
||||
) =>
|
||||
api.post<ClaudeSetupTokenSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import type { AiManagedConnectionSummary, CreateAiConnection, AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
export const aiConnectionsApi = {
|
||||
startLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { restart?: boolean }) => api.post<LocalAiLoginAttempt>(`/companies/${companyId}/ai-connections/local/attempts`, input),
|
||||
checkLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post<LocalAiLoginStatus>(`/companies/${companyId}/ai-connections/local/check`, input),
|
||||
cancelLocalLogin: (companyId: string, sessionId: string) => api.delete(`/companies/${companyId}/ai-connections/local/attempts/${sessionId}`),
|
||||
connectLocal: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post<{ connectionId: string; grantId: string }>(`/companies/${companyId}/ai-connections/local`, input),
|
||||
activeRuns: (companyId: string, connectionId: string) => api.get<Array<{ id: string; agentId: string; agentName: string; status: string }>>(`/companies/${companyId}/ai-connections/${connectionId}/active-runs`),
|
||||
list: (companyId: string, agentId?: string) => api.get<{ currentUserId: string; connections: AiManagedConnectionSummary[] }>(`/companies/${companyId}/ai-connections${agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""}`),
|
||||
create: (companyId: string, input: CreateAiConnection) => api.post<{ connectionId: string; grantId: string }>(`/companies/${companyId}/ai-connections`, input),
|
||||
setDefault: (companyId: string, grantId: string) => api.put(`/companies/${companyId}/ai-connections/default`, { grantId }),
|
||||
loginResult: (companyId: string, sessionId: string) => api.get<{ connectionId: string; grantId: string }>(`/companies/${companyId}/ai-connections/login/${encodeURIComponent(sessionId)}`),
|
||||
};
|
||||
|
|
@ -49,6 +49,7 @@ export type AdapterLoginChrome = "panel" | "onboarding";
|
|||
export const CONNECT_SOURCE_NAMES: Record<string, string> = {
|
||||
claude_local: "Claude",
|
||||
codex_local: "OpenAI",
|
||||
grok_local: "Grok",
|
||||
};
|
||||
|
||||
/** The provider name for a source, falling back to the type when unlisted. */
|
||||
|
|
@ -170,9 +171,12 @@ function LoginCardCopyButton({
|
|||
const [copied, setCopied] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
|
@ -224,9 +228,12 @@ export function OnboardingLoginCodeRow({
|
|||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoCopiedRef = useRef(false);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// An empty code is not a code. The row renders before the server's one-time
|
||||
|
|
@ -261,7 +268,10 @@ export function OnboardingLoginCodeRow({
|
|||
// the code is readable, but the claim waits for the rest of the card
|
||||
// to stop moving — see COPIED_REVEAL_DELAY_MS.
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setCopied(true), COPIED_REVEAL_DELAY_MS);
|
||||
timeoutRef.current = setTimeout(
|
||||
() => setCopied(true),
|
||||
COPIED_REVEAL_DELAY_MS,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Refused. The listener gives it another go when the document comes
|
||||
|
|
@ -282,7 +292,9 @@ export function OnboardingLoginCodeRow({
|
|||
|
||||
return (
|
||||
<div className="flex h-(--sz-44px) items-center gap-2 rounded-lg bg-muted px-4">
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{code}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
{code}
|
||||
</span>
|
||||
<AnimatePresence initial={false}>
|
||||
{copied && (
|
||||
<motion.span
|
||||
|
|
@ -400,3 +412,84 @@ export function OnboardingCardField({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared authentication presentation. Hosts retain their existing session lifecycle. */
|
||||
export function ProviderSubscriptionCard({
|
||||
providerName,
|
||||
authorizationUrl,
|
||||
mode,
|
||||
loading,
|
||||
children,
|
||||
}: {
|
||||
providerName: string;
|
||||
authorizationUrl?: string;
|
||||
mode: "submitted_code" | "displayed_code";
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<OnboardingLoginCard
|
||||
loading={loading}
|
||||
instruction={
|
||||
<>
|
||||
<a
|
||||
href={authorizationUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
Sign in to {providerName}
|
||||
</a>
|
||||
{mode === "submitted_code"
|
||||
? " then come back and enter authorization code"
|
||||
: " by providing the authorization code below"}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</OnboardingLoginCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderApiKeyCard({
|
||||
providerName,
|
||||
...field
|
||||
}: Omit<Parameters<typeof OnboardingCardField>[0], "masked" | "label"> & {
|
||||
providerName: string;
|
||||
}) {
|
||||
return (
|
||||
<OnboardingLoginCard
|
||||
instruction={`Provide your ${providerName} API key to connect`}
|
||||
>
|
||||
<OnboardingCardField {...field} label="API key" masked />
|
||||
</OnboardingLoginCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared instructions for local subscription setup in every authentication host. */
|
||||
export function LocalProviderLoginInstructions({ adapterType, login }: {
|
||||
adapterType: string;
|
||||
login?: { command?: string; preparing: boolean; status?: "ready" | "sign_in_required" | "expired" | null; error: string | null; retry: () => void };
|
||||
}) {
|
||||
const [showCommand, setShowCommand] = useState(false);
|
||||
const provider = adapterType === "claude_local" ? "Claude Code" : adapterType === "grok_local" ? "Grok CLI" : "Codex CLI";
|
||||
const isolated = adapterType === "codex_local" || adapterType === "grok_local";
|
||||
const command = isolated ? login?.command : "claude auth login";
|
||||
if (login?.preparing) return <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Checking local {provider} sign-in…</p>;
|
||||
const ready = login?.status === "ready";
|
||||
return <div className="min-w-0 max-w-full space-y-3 text-sm text-muted-foreground">
|
||||
{ready ? <>
|
||||
<p role="status" className="flex items-center gap-2 text-foreground"><Check className="size-4 shrink-0 text-(--status-task-icon-done)" />{provider} is signed in. Click Connect to use this account.</p>
|
||||
{!showCommand && <button type="button" className="underline underline-offset-4" onClick={() => setShowCommand(true)}>Use a different account</button>}
|
||||
</> : <p>{isolated ? `Sign in to ${provider} for this connection on the machine running Paperclip. Your existing terminal login stays separate.` : `Connect uses your local ${provider} account on the machine running Paperclip.`}</p>}
|
||||
{(!ready || showCommand) && !login?.error && <>
|
||||
<p>Run this in a terminal on that machine and finish signing in in your browser. We’ll check automatically when you return.</p>
|
||||
{command && <div className="flex min-w-0 max-w-full items-start gap-2 rounded-md border bg-muted p-3 text-foreground">
|
||||
<pre className="min-w-0 flex-1 whitespace-pre-wrap break-all font-mono text-xs"><code>{command}</code></pre>
|
||||
<LoginCardCopyButton value={command} label="Copy sign-in command" />
|
||||
</div>}
|
||||
</>}
|
||||
{login?.error && <p role="alert">{login.error}</p>}
|
||||
{login && !login.preparing && (isolated || login.error) && <button type="button" className="underline underline-offset-4" onClick={login.retry}>{isolated ? "Start sign-in again" : "Check again"}</button>}
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { AiConnectionField } from "./ai-connections/AiConnectionField";
|
||||
import { aiConnectionBindingSchema } from "@paperclipai/shared";
|
||||
import { testAgentSetup } from "@/lib/test-agent-setup";
|
||||
import { RuntimeTestCard } from "./RuntimeTestCard";
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, Children, isValidElement, type ReactNode } from "react";
|
||||
|
|
@ -44,7 +46,7 @@ import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
|
|||
import { copyTextToClipboard } from "../lib/clipboard";
|
||||
import {
|
||||
connectSourceName,
|
||||
OnboardingLoginCard,
|
||||
ProviderSubscriptionCard,
|
||||
OnboardingCardField,
|
||||
OnboardingLoginCodeRow,
|
||||
type AdapterLoginChrome,
|
||||
|
|
@ -888,9 +890,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
? String(isCreate ? props.values.adapterSchemaValues?.provider ?? "codex"
|
||||
: eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex"))
|
||||
: undefined;
|
||||
const modelProvider = adapterType === "opencode_local" && aiConnectionBindingSchema.safeParse(
|
||||
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection,
|
||||
).data?.provider === "openrouter" ? "openrouter" : runnerProvider;
|
||||
// Fetch adapter models for the effective provider, including unsaved changes.
|
||||
const modelQueryKey = selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, runnerProvider)
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, modelProvider)
|
||||
: ["agents", "none", "adapter-models", adapterType];
|
||||
const {
|
||||
data: fetchedModels,
|
||||
|
|
@ -899,7 +904,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
queryKey: modelQueryKey,
|
||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, {
|
||||
environmentId: currentDefaultEnvironmentId || null,
|
||||
provider: runnerProvider,
|
||||
provider: modelProvider,
|
||||
}),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
|
@ -1055,15 +1060,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
});
|
||||
const adapterConfig = buildAdapterConfigForTest(adapterConfigPatch);
|
||||
const agentId = isCreate ? undefined : props.agent.id;
|
||||
const aiConnection = isCreate ? undefined : aiConnectionBindingSchema.safeParse(
|
||||
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? props.agent.runtimeConfig.aiConnection,
|
||||
).data;
|
||||
if (props.compactTestFeedback) {
|
||||
const providerAdapter = adapterType === "paperclip_runner"
|
||||
? adapterConfig.provider === "codex" ? "codex_local"
|
||||
: adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local"
|
||||
: adapterType
|
||||
: adapterType;
|
||||
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, environmentId });
|
||||
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId });
|
||||
}
|
||||
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, environmentId });
|
||||
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId });
|
||||
},
|
||||
});
|
||||
const [testActionPending, setTestActionPending] = useState(false);
|
||||
|
|
@ -1139,6 +1147,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsLoginPty === true;
|
||||
const loginNeedsPty = adapterCaps.login != null;
|
||||
const showAdapterLogin =
|
||||
(isCreate || !((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection)) &&
|
||||
adapterSupportsSandboxLogin &&
|
||||
effectiveLoginEnvironment?.driver === "sandbox" &&
|
||||
Boolean(effectiveLoginEnvironmentId) &&
|
||||
|
|
@ -1243,7 +1252,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
setRefreshingModels(true);
|
||||
setRefreshModelsError(null);
|
||||
try {
|
||||
const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: runnerProvider });
|
||||
const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider });
|
||||
queryClient.setQueryData(modelQueryKey, refreshed);
|
||||
} catch (error) {
|
||||
setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models.");
|
||||
|
|
@ -1641,6 +1650,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</Field>
|
||||
)}
|
||||
|
||||
{!isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={adapterType === "paperclip_runner" ? eff("adapterConfig", "provider", config.provider) === "codex" ? "codex_local" : eff("adapterConfig", "provider", config.provider) === "opencode" ? "opencode_local" : eff("adapterConfig", "provider", config.provider) === "acpx" && eff("adapterConfig", "acpxAgent", config.acpxAgent) === "claude" ? "claude_local" : adapterType : adapterType}
|
||||
value={aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data}
|
||||
model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy
|
||||
onChange={binding => mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding })} />}
|
||||
|
||||
{showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && (testActionError || testEnvironment.error) && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{testActionError
|
||||
|
|
@ -2243,6 +2257,7 @@ export type AdapterLoginDescriptor = {
|
|||
// correctly, and the first thing to rot would have been the timeout and
|
||||
// cleanup paths, which are the ones nobody exercises by hand.
|
||||
export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
|
||||
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
|
||||
onStored?: (storedSessionId: string) => void;
|
||||
onApplyStored?: () => void;
|
||||
// Applies the non-secret Codex account-binding claim from an authenticated
|
||||
|
|
@ -2261,7 +2276,7 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
|
|||
// The login reached its success state. Onboarding advances on this, which is
|
||||
// why the `onboarding` chrome draws no success state of its own — the screen
|
||||
// it would appear on is already gone.
|
||||
onConnected?: () => void;
|
||||
onConnected?: (sessionId?: string) => void;
|
||||
// The pasted code went to the server. Fires as the submit starts rather than
|
||||
// when the login finishes, so a caller can show the work the moment the
|
||||
// customer has done their part: the round trip to `onConnected` is a poll
|
||||
|
|
@ -2326,6 +2341,7 @@ function DisplayedCodeLoginPanel({
|
|||
onConnected,
|
||||
onAccountBinding,
|
||||
chrome = "panel",
|
||||
aiConnection,
|
||||
onPromptReady,
|
||||
}: AdapterLoginPanelProps) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
|
|
@ -2350,7 +2366,7 @@ function DisplayedCodeLoginPanel({
|
|||
const resumedRef = useRef(false);
|
||||
|
||||
const startLogin = useMutation({
|
||||
mutationFn: () => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId }),
|
||||
mutationFn: () => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId, aiConnection }),
|
||||
onSuccess: (session) => {
|
||||
resumedRef.current = false;
|
||||
setStartError(null);
|
||||
|
|
@ -2389,7 +2405,10 @@ function DisplayedCodeLoginPanel({
|
|||
queryKey: ["adapter-login-active-session", companyId, adapterType],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType);
|
||||
const active = await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType);
|
||||
if (!active) return null;
|
||||
if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new Error("Another sign-in attempt is active. Finish or cancel it in its original account setup before starting this one.");
|
||||
return active;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
|
|
@ -2531,7 +2550,7 @@ function DisplayedCodeLoginPanel({
|
|||
useEffect(() => {
|
||||
if (status !== "authenticated" || connectedRef.current) return;
|
||||
connectedRef.current = true;
|
||||
onConnectedRef.current?.();
|
||||
onConnectedRef.current?.(sessionId ?? undefined);
|
||||
}, [status]);
|
||||
|
||||
// Drive the account-binding hand-off as a visible state machine, not a
|
||||
|
|
@ -2583,24 +2602,11 @@ function DisplayedCodeLoginPanel({
|
|||
if (chrome === "onboarding") {
|
||||
const failed = isTerminal && status && status !== "authenticated";
|
||||
return (
|
||||
<OnboardingLoginCard
|
||||
<ProviderSubscriptionCard
|
||||
loading={!prompt && !startError && !failed}
|
||||
instruction={
|
||||
<>
|
||||
{/* The same destination as the step's own button. Two ways to one
|
||||
link: the button for the customer following the flow, the anchor
|
||||
for anyone finishing in another browser. */}
|
||||
<a
|
||||
href={prompt?.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
Sign in to {connectSourceName(adapterType)}
|
||||
</a>
|
||||
{" by providing the authorization code below"}
|
||||
</>
|
||||
}
|
||||
providerName={connectSourceName(adapterType)}
|
||||
authorizationUrl={prompt?.url}
|
||||
mode="displayed_code"
|
||||
>
|
||||
{startError ? (
|
||||
<p role="alert" className="pl-2 text-xs text-destructive">
|
||||
|
|
@ -2617,7 +2623,7 @@ function DisplayedCodeLoginPanel({
|
|||
) : (
|
||||
<OnboardingLoginCodeRow code={prompt?.code ?? ""} autoCopy />
|
||||
)}
|
||||
</OnboardingLoginCard>
|
||||
</ProviderSubscriptionCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2817,6 +2823,7 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
onCodeSubmitted,
|
||||
onSubmitFailed,
|
||||
chrome = "panel",
|
||||
aiConnection,
|
||||
onPromptReady,
|
||||
}: AdapterLoginPanelProps) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
|
|
@ -2904,10 +2911,11 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
mutationFn: () =>
|
||||
agentsApi.startClaudeSetupTokenLogin(companyId, {
|
||||
environmentId,
|
||||
aiConnection,
|
||||
// When the owner already has a stored token, the login rotates it under
|
||||
// the captured version, so a replacement login never conflicts with an
|
||||
// existing value. Without a stored token the login is a first write.
|
||||
...(storedToken
|
||||
...(storedToken && !aiConnection
|
||||
? {
|
||||
overwrite: {
|
||||
expectedSecretId: storedToken.secretId,
|
||||
|
|
@ -2981,7 +2989,10 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
queryKey: ["claude-setup-token-active-session", companyId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId);
|
||||
const active = await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId);
|
||||
if (!active) return null;
|
||||
if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new Error("Another sign-in attempt is active. Finish or cancel it in its original account setup before starting this one.");
|
||||
return active;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
|
|
@ -3301,7 +3312,7 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
useEffect(() => {
|
||||
if (!isStored || connectedRef.current) return;
|
||||
connectedRef.current = true;
|
||||
onConnectedRef.current?.();
|
||||
onConnectedRef.current?.(sessionId ?? undefined);
|
||||
}, [isStored]);
|
||||
|
||||
// The other end of `onCodeSubmitted`. Any of these after a submit means the
|
||||
|
|
@ -3325,21 +3336,11 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
if (chrome === "onboarding") {
|
||||
const failedNow = isFailure || timedOut;
|
||||
return (
|
||||
<OnboardingLoginCard
|
||||
<ProviderSubscriptionCard
|
||||
loading={!authorizationUrl && !startError && !failedNow}
|
||||
instruction={
|
||||
<>
|
||||
<a
|
||||
href={authorizationUrl ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
Sign in to {connectSourceName(adapterType)}
|
||||
</a>
|
||||
{" then come back and enter authorization code"}
|
||||
</>
|
||||
}
|
||||
providerName={connectSourceName(adapterType)}
|
||||
authorizationUrl={authorizationUrl ?? undefined}
|
||||
mode="submitted_code"
|
||||
>
|
||||
{/* The plain-HTTP advisory survives the redesign. It is the one thing on
|
||||
this card not about getting the login done, and dropping it to keep
|
||||
|
|
@ -3375,7 +3376,7 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
disabled={submitCode.isPending || isCompleting || codeSubmitted}
|
||||
/>
|
||||
)}
|
||||
</OnboardingLoginCard>
|
||||
</ProviderSubscriptionCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
// the refetch has to answer too — otherwise the identity errors and the list
|
||||
// never runs.
|
||||
const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() }));
|
||||
const localHealth = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
vi.mock("@/api/health", () => ({ healthApi: localHealth }));
|
||||
const managedApi = vi.hoisted(() => ({
|
||||
list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })),
|
||||
startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", command: "CODEX_HOME='/fixture/login' codex login", expiresAt: "2026-09-11T20:00:00Z" })),
|
||||
checkLocalLogin: vi.fn(async () => ({ status: "sign_in_required" as const })),
|
||||
cancelLocalLogin: vi.fn(async () => ({})),
|
||||
connectLocal: vi.fn(async () => ({ connectionId: "local-connection", grantId: "local-grant" })),
|
||||
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
|
||||
}));
|
||||
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi }));
|
||||
vi.mock("../api/auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../api/auth")>();
|
||||
return { ...actual, authApi: { ...actual.authApi, getSession: mockAuthApi.getSession } };
|
||||
|
|
@ -312,6 +323,7 @@ function isArcPrimary(text: string): boolean {
|
|||
|
||||
describe("OnboardingWizard restore-gate (stale localStorage across accounts)", () => {
|
||||
beforeEach(() => {
|
||||
localHealth.get.mockResolvedValue({ deploymentMode: "authenticated" });
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-b", userId: SESSION_USER_ID },
|
||||
user: { id: SESSION_USER_ID, name: "B", email: "b@example.com", image: null },
|
||||
|
|
@ -915,26 +927,14 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
|||
return handles;
|
||||
}
|
||||
|
||||
it("is stored as the user's own secret and referenced, never carried in the hire", async () => {
|
||||
it("is stored as a personal connection and referenced, never carried in the hire", async () => {
|
||||
const { root } = await connectWithApiKey();
|
||||
|
||||
expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1);
|
||||
const [, createBody] = mockSecretsApi.createMyUserSecret.mock.calls.at(-1) as [
|
||||
string,
|
||||
{ definitionKey: string; value: string },
|
||||
];
|
||||
expect(createBody.definitionKey).toMatch(/^ANTHROPIC_API_KEY\.setup\./);
|
||||
expect(createBody.value).toBe(KEY);
|
||||
|
||||
const hireBody = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as {
|
||||
adapterConfig: { env?: Record<string, unknown> };
|
||||
};
|
||||
// The same binding kind the subscription half of this step produces.
|
||||
expect(hireBody.adapterConfig.env?.ANTHROPIC_API_KEY).toEqual({
|
||||
type: "user_secret_ref",
|
||||
key: createBody.definitionKey,
|
||||
version: "latest",
|
||||
});
|
||||
expect(managedApi.create).toHaveBeenCalledTimes(1);
|
||||
expect(managedApi.create).toHaveBeenCalledWith("company-new", expect.objectContaining({ provider: "anthropic", method: "api_key", ownership: "personal", apiKey: KEY }));
|
||||
const hireBody = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as { runtimeConfig: { aiConnection: unknown }; adapterConfig: { env?: Record<string, unknown> } };
|
||||
expect(hireBody.runtimeConfig.aiConnection).toEqual({ provider: "anthropic", method: "api_key", mode: "responsible_user" });
|
||||
expect(hireBody.adapterConfig.env?.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
// The whole payload, not just that one field: the point is that the key
|
||||
// is nowhere in what gets persisted, however it might be nested.
|
||||
expect(JSON.stringify(hireBody)).not.toContain(KEY);
|
||||
|
|
@ -942,23 +942,21 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
|||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("creates a distinct definition instead of rotating an existing key", async () => {
|
||||
it("creates a managed connection without rotating an existing saved key", async () => {
|
||||
mockSecretsApi.listMyUserSecrets.mockResolvedValue([
|
||||
{ definition: { id: "old-def", key: "ANTHROPIC_API_KEY" }, secret: { id: "secret-existing" } },
|
||||
]);
|
||||
const { root } = await connectWithApiKey();
|
||||
expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledWith(
|
||||
expect.any(String), expect.objectContaining({ key: expect.stringMatching(/^ANTHROPIC_API_KEY\.setup\./) }),
|
||||
);
|
||||
expect(managedApi.create).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ apiKey: KEY }));
|
||||
expect(mockSecretsApi.rotateMyUserSecret).not.toHaveBeenCalled();
|
||||
expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1);
|
||||
expect(managedApi.create).toHaveBeenCalledTimes(1);
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
// The one outcome that must never happen is a hire that falls back to
|
||||
// embedding the key because storing it failed.
|
||||
it("blocks the hire when the key cannot be stored", async () => {
|
||||
mockSecretsApi.createMyUserSecret.mockRejectedValue(new Error("vault unreachable"));
|
||||
managedApi.create.mockRejectedValueOnce(new Error("vault unreachable"));
|
||||
const { root } = await connectWithApiKey();
|
||||
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
|
|
@ -967,13 +965,13 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
|||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("stores one secret when Connect is pressed twice with the same key", async () => {
|
||||
it("stores one connection when Connect is pressed twice with the same key", async () => {
|
||||
mockAgentsApi.hire.mockRejectedValueOnce(new Error("network went away"));
|
||||
const { root, clickByText } = await connectWithApiKey();
|
||||
|
||||
await clickByText((t) => isArcPrimary(t));
|
||||
|
||||
expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1);
|
||||
expect(managedApi.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
|
@ -3045,6 +3043,27 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
|||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("shows local Claude instructions and saves its connection before hiring", async () => {
|
||||
localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" });
|
||||
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
|
||||
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
|
||||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||||
await pickSource(/Claude/);
|
||||
expect(document.body.textContent).toContain("claude auth login");
|
||||
expect(document.body.textContent).toContain("machine running Paperclip");
|
||||
expect(document.body.textContent).not.toContain("No managed sandbox");
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
|
||||
const connect = [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim().startsWith("Connect"));
|
||||
expect(connect).toBeTruthy();
|
||||
await act(async () => connect!.click());
|
||||
for (let i = 0; i < 6; i++) await flushReact();
|
||||
expect(managedApi.connectLocal).toHaveBeenCalledWith("company-new", expect.objectContaining({ provider: "anthropic", method: "subscription", ownership: "personal" }));
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalled();
|
||||
const hire = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as { runtimeConfig: { aiConnection: unknown } };
|
||||
expect(hire.runtimeConfig.aiConnection).toEqual({ provider: "anthropic", method: "subscription", mode: "responsible_user" });
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("hides the panel when the resolved login environment driver is not sandbox", async () => {
|
||||
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
|
||||
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import { healthApi } from "@/api/health";
|
||||
import { LocalProviderLoginInstructions } from "./AdapterLoginChrome";
|
||||
import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { aiProviderForAdapter } from "./ai-connections/AiConnectionField";
|
||||
import type { AiConnectionBinding } from "@paperclipai/shared";
|
||||
import { storeProviderApiKey } from "../lib/provider-credential";
|
||||
import { SavedProviderKeySelect, useSavedProviderKeys } from "./onboarding/SavedProviderKeySelect";
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
|
|
@ -671,7 +677,7 @@ function OnboardingWizardInner({
|
|||
effectiveOnboardingOpen && step === 4,
|
||||
);
|
||||
const [subscriptionId, setSubscriptionId] = useState<{ companyId: string; id: string } | null>(null);
|
||||
const savedSubscription = adapterType === "codex_local"
|
||||
const savedSubscription = savedKeys.subscriptions.length > 0
|
||||
? savedKeys.subscriptions.find((option) => option.id === (
|
||||
subscriptionId?.companyId === createdCompanyId
|
||||
? subscriptionId.id
|
||||
|
|
@ -684,8 +690,8 @@ function OnboardingWizardInner({
|
|||
: savedKeys.options[0]?.id;
|
||||
const selectedApiKey = savedKeys.options.find((option) => option.id === selectedApiKeyId);
|
||||
const credentialMode = credentialModeChoice ?? (
|
||||
(adapterType === "claude_local" ? savedKeys.storedLogin.data : adapterType === "codex_local" && savedKeys.subscriptions.length)
|
||||
? "subscription" : savedKeys.options.length ? "api" : "subscription"
|
||||
(savedKeys.subscriptions.length > 0 || (adapterType === "claude_local" && savedKeys.storedLogin.data))
|
||||
? "subscription" : savedKeys.options.length || adapterType === "opencode_local" ? "api" : "subscription"
|
||||
);
|
||||
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
|
||||
string | null
|
||||
|
|
@ -732,7 +738,15 @@ function OnboardingWizardInner({
|
|||
* customer on the step to try again — and without this each press would store
|
||||
* another copy of the same credential.
|
||||
*/
|
||||
const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; binding: Awaited<ReturnType<typeof storeProviderApiKey>>["binding"] } | null>(null);
|
||||
const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; binding?: Awaited<ReturnType<typeof storeProviderApiKey>>["binding"]; aiConnection?: AiConnectionBinding } | null>(null);
|
||||
const managedSubscriptionRef = useRef<{ companyId: string; binding: AiConnectionBinding } | null>(null);
|
||||
const managedProvider = aiProviderForAdapter(adapterType);
|
||||
function managedBindingForStep(): AiConnectionBinding | undefined {
|
||||
if (credentialMode === "api") return selectedApiKey?.aiConnection ?? (
|
||||
!selectedApiKey && apiKeySecretRef.current?.companyId === createdCompanyId && apiKeySecretRef.current.envKey === apiKeyEnvKeyFor(adapterType)
|
||||
? apiKeySecretRef.current.aiConnection : undefined);
|
||||
return savedSubscription?.aiConnection ?? (managedSubscriptionRef.current?.companyId === createdCompanyId && managedSubscriptionRef.current.binding.provider === managedProvider ? managedSubscriptionRef.current.binding : undefined);
|
||||
}
|
||||
createdCompanyIdRef.current = createdCompanyId;
|
||||
|
||||
// The step the request wants, mirrored for the same reason. `initialStep` is
|
||||
|
|
@ -977,6 +991,14 @@ function OnboardingWizardInner({
|
|||
// full adapter test result. The cheap auth signal below stands in for that
|
||||
// input here, so this gate alone only decides whether the login mechanism
|
||||
// could ever apply to the current adapter and environment.
|
||||
const localLoginHealth = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get });
|
||||
const canUseLocalLogin = resolvedLoginEnvironment?.driver === "local" && localLoginHealth.data?.deploymentMode === "local_trusted";
|
||||
const localLogin = useLocalAiLogin(createdCompanyId, {
|
||||
provider: managedProvider ?? "anthropic", method: "subscription",
|
||||
name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`,
|
||||
ownership: "personal", agentIds: [], allAgents: true,
|
||||
}, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" &&
|
||||
Boolean(managedProvider) && !savedSubscription && !savedKeys.storedLogin.data && !managedBindingForStep());
|
||||
const canShowAdapterLogin = Boolean(
|
||||
adapterCaps.login != null &&
|
||||
resolvedLoginEnvironment?.driver === "sandbox" &&
|
||||
|
|
@ -1123,8 +1145,7 @@ function OnboardingWizardInner({
|
|||
* The same four conditions the card itself renders on, named once so the
|
||||
* footer button and the card cannot disagree about whether a login is
|
||||
* happening. When it is false — an API key, a source already signed in on the
|
||||
* sandbox, no sandbox to sign in against — Connect goes straight to the hire,
|
||||
* exactly as it did before.
|
||||
* sandbox, or a local CLI account — Connect verifies credentials before the hire.
|
||||
*/
|
||||
const connectStepNeedsLogin = Boolean(
|
||||
credentialMode !== "api" &&
|
||||
|
|
@ -1149,17 +1170,7 @@ function OnboardingWizardInner({
|
|||
const loginSubmitsBrowserCode =
|
||||
adapterCaps.login?.panelMode === "submitted_browser_code";
|
||||
|
||||
/**
|
||||
* The one thing that can be wrong here before anything is pressed: there is
|
||||
* no sandbox to sign in against, so Connect cannot get anywhere. Worth saying
|
||||
* on arrival rather than after a press that goes nowhere.
|
||||
*
|
||||
* Its two neighbours in the old canvas are not worth the same. "Checking this
|
||||
* source's credentials…" narrated a request nothing was waiting on, and "this
|
||||
* source is already signed in" answered a question the customer had not asked
|
||||
* yet — both were written for a canvas that opened on selection, and the
|
||||
* press is what opens it now.
|
||||
*/
|
||||
/** Without browser login, show instructions for the selected execution environment. */
|
||||
const connectStepHasNoSandbox =
|
||||
credentialMode !== "api" && !canShowAdapterLogin && !authSignalUndecided;
|
||||
|
||||
|
|
@ -1720,6 +1731,11 @@ function OnboardingWizardInner({
|
|||
const envKey = apiKeyEnvKeyFor(adapterType);
|
||||
if (apiKeySecretRef.current?.key === key && apiKeySecretRef.current.companyId === companyId && apiKeySecretRef.current.envKey === envKey) return true;
|
||||
try {
|
||||
if (managedProvider) {
|
||||
await aiConnectionsApi.create(companyId, { provider: managedProvider, method: "api_key", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} API`, ownership: "personal", apiKey: key, agentIds: [], allAgents: true });
|
||||
apiKeySecretRef.current = { key, companyId, envKey, aiConnection: { provider: managedProvider, method: "api_key", mode: "responsible_user" } };
|
||||
return true;
|
||||
}
|
||||
const stored = await storeProviderApiKey(companyId, envKey, key);
|
||||
apiKeySecretRef.current = { key, companyId, envKey, binding: stored.binding };
|
||||
return true;
|
||||
|
|
@ -1785,7 +1801,7 @@ function OnboardingWizardInner({
|
|||
// present. If storing failed this stays false, and the right outcome is a
|
||||
// configuration with no credential — which the hire then blocks on — rather
|
||||
// than one that quietly falls back to embedding the value.
|
||||
if (credentialMode === "api" && (bindApiKey || selectedApiKey)) {
|
||||
if (!managedBindingForStep() && credentialMode === "api" && (bindApiKey || selectedApiKey)) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? { ...(config.env as Record<string, unknown>) }
|
||||
|
|
@ -1793,7 +1809,7 @@ function OnboardingWizardInner({
|
|||
env[apiKeyEnvKeyFor(adapterType)] = selectedApiKey?.binding ?? apiKeySecretRef.current?.binding;
|
||||
config.env = env;
|
||||
}
|
||||
if (credentialMode === "subscription" && savedSubscription) {
|
||||
if (credentialMode === "subscription" && savedSubscription?.binding) {
|
||||
config.env = { ...((config.env as object) ?? {}), CODEX_HOME: savedSubscription.binding };
|
||||
}
|
||||
return config;
|
||||
|
|
@ -1866,6 +1882,7 @@ function OnboardingWizardInner({
|
|||
adapterType,
|
||||
{
|
||||
adapterConfig: adapterConfigOverride ?? buildAdapterConfig(),
|
||||
...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}),
|
||||
environmentId,
|
||||
}
|
||||
);
|
||||
|
|
@ -2002,10 +2019,15 @@ function OnboardingWizardInner({
|
|||
apiKeyStored = await storeApiKeyUserSecret(createdCompanyId);
|
||||
if (!apiKeyStored) return;
|
||||
}
|
||||
if (credentialMode !== "api" && canUseLocalLogin && managedProvider && !managedBindingForStep() && !savedSubscription && !savedKeys.storedLogin.data) {
|
||||
await localLogin.connect();
|
||||
managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedProvider, method: "subscription", mode: "responsible_user" } };
|
||||
}
|
||||
const managedBinding = managedBindingForStep();
|
||||
const baseAdapterConfig = buildAdapterConfig(apiKeyStored);
|
||||
let storedClaudeLogin: ClaudeOAuthTokenStatusResponse | null = null;
|
||||
if (
|
||||
adapterType === "claude_local" &&
|
||||
!managedBinding && adapterType === "claude_local" &&
|
||||
!adapterConfigHasAnthropicApiKey(baseAdapterConfig)
|
||||
) {
|
||||
try {
|
||||
|
|
@ -2102,7 +2124,7 @@ function OnboardingWizardInner({
|
|||
// the chief-of-staff persona over the agent's entry instruction file.
|
||||
// The wizard no longer composes or overwrites it.
|
||||
onboardingFirstAgent: true,
|
||||
runtimeConfig: buildNewAgentRuntimeConfig()
|
||||
runtimeConfig: { ...buildNewAgentRuntimeConfig(), ...(managedBinding ? { aiConnection: managedBinding } : {}) }
|
||||
});
|
||||
if (hire.approval) {
|
||||
await approvalsApi.approve(
|
||||
|
|
@ -2554,7 +2576,7 @@ function OnboardingWizardInner({
|
|||
}}
|
||||
/>
|
||||
|
||||
{credentialMode === "subscription" && adapterType === "codex_local" && savedKeys.subscriptions.length > 0 && (
|
||||
{credentialMode === "subscription" && savedKeys.subscriptions.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<SavedProviderKeySelect
|
||||
options={savedKeys.subscriptions}
|
||||
|
|
@ -2681,6 +2703,7 @@ function OnboardingWizardInner({
|
|||
adapterType={adapterType}
|
||||
environmentId={resolvedLoginEnvironmentId}
|
||||
chrome="onboarding"
|
||||
aiConnection={managedProvider ? { provider: managedProvider, method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true } : undefined}
|
||||
autoStart
|
||||
onPromptReady={(url) => {
|
||||
setConnectAuthUrl(url);
|
||||
|
|
@ -2715,6 +2738,7 @@ function OnboardingWizardInner({
|
|||
);
|
||||
}}
|
||||
onConnected={() => {
|
||||
if (managedProvider) managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedProvider, method: "subscription", mode: "responsible_user" } };
|
||||
// Not into a card the customer has left. The panel is
|
||||
// still mounted through Back's exit, and a login that
|
||||
// finished there pulled the step back into "Connecting"
|
||||
|
|
@ -2754,12 +2778,9 @@ function OnboardingWizardInner({
|
|||
) : adapterType === "claude_local" && savedKeys.storedLogin.data ? (
|
||||
<p className="text-sm text-muted-foreground">Use your saved Claude subscription for this agent.</p>
|
||||
) : connectStepHasNoSandbox ? (
|
||||
/* The one thing that can be wrong here before anything is
|
||||
pressed, and the one worth saying out loud: without a
|
||||
sandbox there is nothing to sign in against. */
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No managed sandbox is available to sign in against yet.
|
||||
</p>
|
||||
resolvedLoginEnvironment?.driver === "local" && managedProvider ? (
|
||||
<LocalProviderLoginInstructions adapterType={adapterType} login={{ ...localLogin, retry: () => { setError(null); localLogin.retry(); } }} />
|
||||
) : <p className="text-xs text-muted-foreground">This environment does not support browser sign-in. Choose another sign-in environment or connect with an API key.</p>
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ import { cn } from "@/lib/utils";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
|
||||
import { latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages";
|
||||
import { isRedundantAiRecoveryNotice, latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages";
|
||||
import { isLiveIssueRun, isTerminalIssueStatus } from "@/lib/liveIssueIds";
|
||||
import {
|
||||
resolveTaskChatBlockers,
|
||||
|
|
@ -762,6 +762,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
const projectedComments = useMemo(
|
||||
() =>
|
||||
comments.flatMap((comment) => {
|
||||
if (isRedundantAiRecoveryNotice(comment, interactions)) return [];
|
||||
if (comment.body !== LEGACY_WITHHELD_RUN_COMMENT || !comment.runId)
|
||||
return [comment];
|
||||
const resultJson = linkedRunMetaById.get(comment.runId)?.resultJson;
|
||||
|
|
@ -774,7 +775,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
const summary = acceptedSemanticResultSummary(resultJson);
|
||||
return [summary ? { ...comment, body: summary } : comment];
|
||||
}),
|
||||
[comments, linkedRunMetaById],
|
||||
[comments, interactions, linkedRunMetaById],
|
||||
);
|
||||
|
||||
const commentItems = useMemo(
|
||||
|
|
@ -1632,8 +1633,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
const retryDetail = meta?.scheduledRetryAt
|
||||
? "Retry scheduled automatically."
|
||||
: "You can retry this message now.";
|
||||
const detail =
|
||||
source.status === "cancelled"
|
||||
const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id);
|
||||
const detail = aiRequest
|
||||
? aiRequest.status === "pending"
|
||||
? "The selected AI account is unavailable. Fix it in the connection card."
|
||||
: "This run stopped because its AI account was unavailable."
|
||||
: source.status === "cancelled"
|
||||
? code === "execution_reconciliation_required"
|
||||
? "The previous execution must be checked before this task can continue. Your message is preserved. View the stopped run for details."
|
||||
: "Execution was stopped before returning an answer."
|
||||
|
|
@ -1970,6 +1975,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
};
|
||||
}, [
|
||||
orderedEntries,
|
||||
interactions,
|
||||
runs,
|
||||
liveRun,
|
||||
transcriptByRun,
|
||||
|
|
@ -2858,7 +2864,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
: (liveRun && liveRun.id === tailRunId
|
||||
? liveRun.currentStatusMessage
|
||||
: null) ||
|
||||
"Waiting for transcript..."
|
||||
(tailStatus === "failed"
|
||||
? "This run stopped before a response was available. Review the task’s connection or recovery action below."
|
||||
: "Waiting for transcript...")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { CheckCircle2, RefreshCw, Star, TriangleAlert, 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 { aiMethodLabel, type AiConnectionSummary } from "./model";
|
||||
|
||||
/** AI-only account controls; identity, access and navigation belong to AppDetail. */
|
||||
export function AiConnectionAccountControls({
|
||||
account, grant, currentUserId, readOnly, onMakeDefault, onReconnect, onRevoke, revocationDetails,
|
||||
}: {
|
||||
account: AiConnectionSummary;
|
||||
grant: ConnectionGrant;
|
||||
currentUserId: string;
|
||||
readOnly?: boolean;
|
||||
onMakeDefault: () => void;
|
||||
onReconnect: () => void;
|
||||
onRevoke: () => void | Promise<void>;
|
||||
revocationDetails?: ReactNode;
|
||||
}) {
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const [revokePending, setRevokePending] = useState(false);
|
||||
const [revokeError, setRevokeError] = useState<string>();
|
||||
const ownPersonal = account.ownership === "personal" && account.ownerUserId === currentUserId;
|
||||
const available = account.status === "connected";
|
||||
const activeDefault = account.isDefault && available;
|
||||
return (
|
||||
<section className="space-y-4" aria-label="AI account settings">
|
||||
{ownPersonal && (
|
||||
<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")} />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Personal default</h3>
|
||||
<p className="text-xs text-muted-foreground">{aiMethodLabel(account.provider, account.method)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{account.isDefault ? (
|
||||
<span role="status" className={cn("inline-flex items-center gap-1.5 text-sm font-medium", available ? "text-(--status-task-icon-done)" : "text-destructive")}>
|
||||
{available ? <CheckCircle2 className="size-4" aria-hidden /> : <TriangleAlert className="size-4" aria-hidden />}
|
||||
{available ? "Your default" : "Default unavailable"}
|
||||
</span>
|
||||
) : !readOnly ? (
|
||||
<Button variant="outline" size="sm" disabled={!available} onClick={onMakeDefault}>Make default</Button>
|
||||
) : <span className="text-xs text-muted-foreground">Not your default</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 text-sm">
|
||||
<p className="font-medium">{aiMethodLabel(account.provider, account.method)}</p>
|
||||
{account.accountLabel && <p className="break-words text-xs text-muted-foreground">{account.accountLabel}</p>}
|
||||
</div>
|
||||
{!readOnly && grant.capabilities?.canRevoke && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{<Button variant="outline" size="sm" onClick={onReconnect}><RefreshCw className="size-4" aria-hidden />Reconnect</Button>}
|
||||
{account.status !== "revoked" && <Button variant="ghost" size="sm" className="text-destructive" onClick={() => setRevoking(true)}><Unplug className="size-4" aria-hidden />Revoke identity</Button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{revoking && <RevokeGrantDialog grant={grant} providerName={account.name} pending={revokePending} credentialPolicy={account.ownership === "shared" ? "shared" : "per_user"} isOwnIdentity={account.ownerUserId === currentUserId}
|
||||
description="New runs using this account will be blocked. Existing runs may retain credentials already issued to them. No other account will be selected automatically."
|
||||
onCancel={() => setRevoking(false)} onConfirm={async () => { setRevokePending(true); setRevokeError(undefined); try { await onRevoke(); setRevoking(false); } catch (error) { setRevokeError(error instanceof Error ? error.message : "Could not revoke this account. Retry."); } finally { setRevokePending(false); } }}>{revokeError && <p role="alert" className="text-sm text-destructive">{revokeError}</p>}{revocationDetails}</RevokeGrantDialog>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AiConnectionAuth,
|
||||
type AiConnectionAuthProps,
|
||||
} from "./AiConnectionAuth";
|
||||
|
||||
let root: Root | undefined;
|
||||
afterEach(() => {
|
||||
if (root) flushSync(() => root?.unmount());
|
||||
root = undefined;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
function mount(overrides: Partial<AiConnectionAuthProps> = {}) {
|
||||
const props: AiConnectionAuthProps = {
|
||||
provider: "openai",
|
||||
method: "api_key",
|
||||
state: { phase: "idle" },
|
||||
onStart: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
const render = (next: Partial<AiConnectionAuthProps>) =>
|
||||
flushSync(() => root!.render(<AiConnectionAuth {...props} {...next} />));
|
||||
render({});
|
||||
return { props, container, render };
|
||||
}
|
||||
function typeInput(input: HTMLInputElement, value: string) {
|
||||
flushSync(() => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("AI connection authentication presentation", () => {
|
||||
it("masks keys, hands them only to the injected action, and clears after submission", () => {
|
||||
const { container, props } = mount();
|
||||
const input = container.querySelector("input")!;
|
||||
expect(input.type).toBe("password");
|
||||
typeInput(input, "example-only-key");
|
||||
flushSync(() =>
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
||||
),
|
||||
);
|
||||
expect(props.onSubmit).toHaveBeenCalledWith("example-only-key");
|
||||
expect(input.value).toBe("");
|
||||
expect(container.textContent).not.toContain("example-only-key");
|
||||
});
|
||||
it("drops private input when the provider or lifecycle phase changes", () => {
|
||||
const { container, render } = mount();
|
||||
typeInput(container.querySelector("input")!, "example-only-key");
|
||||
render({ provider: "anthropic" });
|
||||
expect(container.querySelector("input")!.value).toBe("");
|
||||
typeInput(container.querySelector("input")!, "retry-key");
|
||||
render({ state: { phase: "error", message: "Rejected" } });
|
||||
expect(container.querySelector("input")!.value).toBe("");
|
||||
});
|
||||
it("cancellation clears input and invokes only cancellation", () => {
|
||||
const { container, props } = mount();
|
||||
typeInput(container.querySelector("input")!, "example-only-key");
|
||||
flushSync(() =>
|
||||
[...container.querySelectorAll("button")]
|
||||
.find((button) => button.textContent === "Cancel")!
|
||||
.click(),
|
||||
);
|
||||
expect(props.onCancel).toHaveBeenCalledOnce();
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("input")!.value).toBe("");
|
||||
});
|
||||
it("never offers an OpenRouter subscription or starts a provider call on render", () => {
|
||||
const { container, props } = mount({
|
||||
provider: "openrouter",
|
||||
method: "subscription",
|
||||
});
|
||||
expect(container.textContent).toContain("does not offer a subscription");
|
||||
expect(container.querySelector("input")).toBeNull();
|
||||
expect(props.onStart).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
OnboardingCardField,
|
||||
OnboardingLoginCodeRow,
|
||||
ProviderApiKeyCard,
|
||||
ProviderSubscriptionCard,
|
||||
} from "@/components/AdapterLoginChrome";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
aiMethodLabel,
|
||||
type AiAuthMethod,
|
||||
type AiProvider,
|
||||
} from "./model";
|
||||
|
||||
/** Redacted view of the existing login lifecycle, supplied by the host. */
|
||||
export type AiAuthState =
|
||||
| { phase: "idle" | "starting" | "submitting" | "connected" | "cancelled" }
|
||||
| { phase: "waiting"; authorizationUrl: string; code?: string }
|
||||
| { phase: "error" | "expired" | "unsupported"; message: string };
|
||||
|
||||
export interface AiConnectionAuthProps {
|
||||
provider: AiProvider;
|
||||
method: AiAuthMethod;
|
||||
state: AiAuthState;
|
||||
onStart: () => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
/** No provider calls or polling here: live hosts keep the existing login controllers. */
|
||||
export function AiConnectionAuth(props: AiConnectionAuthProps) {
|
||||
// Remount private input state when the provider, method, or attempt changes phase.
|
||||
return (
|
||||
<AuthAttempt
|
||||
key={`${props.provider}:${props.method}:${props.state.phase}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthAttempt({
|
||||
provider,
|
||||
method,
|
||||
state,
|
||||
onStart,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onDone,
|
||||
}: AiConnectionAuthProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const info = AI_PROVIDERS[provider];
|
||||
const busy = state.phase === "starting" || state.phase === "submitting";
|
||||
const unsupported =
|
||||
state.phase === "unsupported" ||
|
||||
(method === "subscription" && !info.subscriptionName);
|
||||
const submit = () => {
|
||||
if (!value.trim() || busy) return;
|
||||
const submitted = value.trim();
|
||||
setValue("");
|
||||
onSubmit(submitted);
|
||||
};
|
||||
return (
|
||||
<section
|
||||
aria-label={`Connect ${info.name}`}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-semibold">Connect {info.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{aiMethodLabel(provider, method)}
|
||||
</p>
|
||||
</div>
|
||||
{state.phase === "connected" ? (
|
||||
<>
|
||||
<p role="status" className="text-sm">
|
||||
Connected. This account is saved in Connections and can be reused.
|
||||
</p>
|
||||
<Button onClick={onDone}>Use connection</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{unsupported ? (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{state.phase === "unsupported"
|
||||
? state.message
|
||||
: "This provider does not offer a subscription connection."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{(state.phase === "error" || state.phase === "expired") && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
{state.phase === "cancelled" && (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
Sign-in cancelled. No connection was created.
|
||||
</p>
|
||||
)}
|
||||
{method === "api_key" ? (
|
||||
<ProviderApiKeyCard
|
||||
providerName={info.name}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={submit}
|
||||
placeholder="Enter API key here"
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
/>
|
||||
) : busy ? (
|
||||
<ProviderSubscriptionCard
|
||||
providerName={info.name}
|
||||
mode={
|
||||
provider === "anthropic"
|
||||
? "submitted_code"
|
||||
: "displayed_code"
|
||||
}
|
||||
loading
|
||||
>
|
||||
<span />
|
||||
</ProviderSubscriptionCard>
|
||||
) : state.phase === "waiting" ? (
|
||||
<ProviderSubscriptionCard
|
||||
providerName={info.name}
|
||||
authorizationUrl={state.authorizationUrl}
|
||||
mode={
|
||||
provider === "anthropic"
|
||||
? "submitted_code"
|
||||
: "displayed_code"
|
||||
}
|
||||
>
|
||||
{provider === "anthropic" ? (
|
||||
<OnboardingCardField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
) : (
|
||||
<OnboardingLoginCodeRow code={state.code ?? ""} />
|
||||
)}
|
||||
</ProviderSubscriptionCard>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sign in with your {info.subscriptionName}.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setValue("");
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{!unsupported &&
|
||||
(method === "api_key" ? (
|
||||
<Button disabled={busy || !value.trim()} onClick={submit}>
|
||||
{busy ? "Connecting…" : "Connect"}
|
||||
</Button>
|
||||
) : state.phase === "waiting" ? (
|
||||
provider === "anthropic" ? (
|
||||
<Button disabled={!value.trim()} onClick={submit}>
|
||||
Submit code
|
||||
</Button>
|
||||
) : (
|
||||
<span role="status" className="text-sm text-muted-foreground">
|
||||
Waiting for sign-in…
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<Button disabled={busy} onClick={onStart}>
|
||||
{busy
|
||||
? "Preparing sign-in…"
|
||||
: state.phase === "idle"
|
||||
? "Sign in"
|
||||
: "Try again"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { type AiProvider, type AiAuthMethod, type AiConnectionLoginIntent } from "@paperclipai/shared";
|
||||
import { AgentProviderConnection } from "@/components/new-agent/AgentProviderConnection";
|
||||
import { ProviderApiKeyCard } from "@/components/AdapterLoginChrome";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { environmentsApi } from "@/api/environments";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { resolveAdapterTestEnvironmentId, resolveLocalDefaultEnvironmentId, resolveManagedSandboxEnvironmentId } from "@/lib/adapter-test-environment";
|
||||
import { resolveForcedKubernetesEnvironment } from "@/lib/forced-kubernetes-environment";
|
||||
|
||||
type Props = {
|
||||
companyId: string;
|
||||
provider: AiProvider;
|
||||
initialMethod?: AiAuthMethod;
|
||||
fixedMethod?: boolean;
|
||||
connectionId?: string;
|
||||
name: string;
|
||||
ownership: "personal" | "shared";
|
||||
agentIds: string[];
|
||||
allAgents: boolean;
|
||||
environmentId?: string;
|
||||
onComplete: (result: { connectionId: string; grantId: string; method: AiAuthMethod }) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/** Connections hosts the same provider step as agent setup, with its own save intent. */
|
||||
export function AiConnectionCredentialStep(props: Props) {
|
||||
if (props.provider === "openrouter") return <ApiKeyConnectionStep {...props} />;
|
||||
return <SubscriptionConnectionStep {...props} />;
|
||||
}
|
||||
|
||||
function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedMethod, connectionId, name: initialName, ownership, agentIds, allAgents, environmentId: suppliedEnvironmentId, onComplete, onCancel }: Props) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [chosenEnvironment, setChosenEnvironment] = useState<string>();
|
||||
const client = useQueryClient();
|
||||
const envs = useQuery({ queryKey: queryKeys.environments.list(companyId), queryFn: () => environmentsApi.list(companyId) });
|
||||
const caps = useQuery({ queryKey: queryKeys.environments.capabilities(companyId), queryFn: () => environmentsApi.capabilities(companyId) });
|
||||
const settings = useQuery({ queryKey: queryKeys.instance.settings, queryFn: instanceSettingsApi.get });
|
||||
const experimental = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: instanceSettingsApi.getExperimental });
|
||||
const general = useQuery({ queryKey: queryKeys.instance.generalSettings, queryFn: instanceSettingsApi.getGeneral });
|
||||
const forced = resolveForcedKubernetesEnvironment(general.data?.executionMode, envs.data ?? []);
|
||||
let environmentId: string | null = null;
|
||||
let environmentError: string | undefined;
|
||||
try {
|
||||
environmentId = forced.forced ? forced.kubernetesEnvironment?.id ?? null : resolveAdapterTestEnvironmentId({
|
||||
agentDefaultEnvironmentId: suppliedEnvironmentId ?? chosenEnvironment,
|
||||
instanceDefaultEnvironmentId: settings.data?.defaultEnvironmentId,
|
||||
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(envs.data),
|
||||
managedSandboxOnly: experimental.data?.enableManagedSandboxOnly,
|
||||
managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(envs.data),
|
||||
visibleEnvironmentIds: envs.data?.map((env) => env.id),
|
||||
});
|
||||
} catch (error) { environmentError = error instanceof Error ? error.message : "Could not resolve the sign-in environment."; }
|
||||
const loginEnvironments = (envs.data ?? []).filter((env) =>
|
||||
env.status === "active" && (env.driver === "local" || (env.driver === "sandbox" &&
|
||||
typeof env.config.provider === "string" &&
|
||||
caps.data?.sandboxProviders?.[env.config.provider]?.supportsLoginPty === true)),
|
||||
);
|
||||
// Signing in may use a different environment from later agent execution.
|
||||
// Prefer a supported login environment without changing any agent routing.
|
||||
if (!forced.forced && !suppliedEnvironmentId && !chosenEnvironment &&
|
||||
!loginEnvironments.some((env) => env.id === environmentId)) {
|
||||
environmentId = loginEnvironments[0]?.id ?? null;
|
||||
}
|
||||
const environment = envs.data?.find((env) => env.id === environmentId);
|
||||
const sandboxProvider = typeof environment?.config.provider === "string" ? environment.config.provider : "";
|
||||
const canLogin = environment?.driver === "sandbox" && caps.data?.sandboxProviders?.[sandboxProvider]?.supportsLoginPty === true;
|
||||
const loading = [envs, caps, settings, experimental, general].some((query) => query.isPending);
|
||||
const error = environmentError ?? [envs, caps, settings, experimental, general].find((query) => query.error)?.error?.message;
|
||||
const intent: AiConnectionLoginIntent = { provider, method: "subscription", name, ownership, agentIds, allAgents, connectionId };
|
||||
return <div className="mx-auto w-full min-w-0 max-w-xl space-y-6">
|
||||
<label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(connectionId)} /></label>
|
||||
{!suppliedEnvironmentId && !forced.forced && loginEnvironments.length > 1 && <Select value={environmentId ?? ""} onValueChange={setChosenEnvironment}>
|
||||
<SelectTrigger aria-label="Sign-in environment"><SelectValue placeholder="Sign-in environment" /></SelectTrigger>
|
||||
<SelectContent>{loginEnvironments.map((env) => <SelectItem key={env.id} value={env.id}>{env.name}</SelectItem>)}</SelectContent>
|
||||
</Select>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
{loading ? <p role="status" className="text-sm text-muted-foreground">Preparing sign-in…</p> : <AgentProviderConnection
|
||||
key={environmentId ?? "local"}
|
||||
companyId={companyId}
|
||||
adapterType={provider === "anthropic" ? "claude_local" : provider === "xai" ? "grok_local" : "codex_local"}
|
||||
environmentId={environmentId}
|
||||
canLogin={canLogin}
|
||||
localEnvironment={environment?.driver === "local"}
|
||||
onBack={onCancel}
|
||||
onConnected={() => {}}
|
||||
testConnection={async () => false}
|
||||
managedAccount={{ intent, initialMethod, fixedMethod: fixedMethod || Boolean(connectionId), disabled: loading || Boolean(error) || !name.trim(), onComplete: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); onComplete(result); } }}
|
||||
/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initialName, ownership, agentIds, allAgents, onComplete, onCancel }: Props) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const client = useQueryClient();
|
||||
const save = useMutation({
|
||||
mutationFn: () => aiConnectionsApi.create(companyId, { provider, method: "api_key", name, ownership, agentIds, allAgents, connectionId, apiKey }),
|
||||
onSuccess: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); onComplete({ ...result, method: "api_key" }); },
|
||||
onSettled: () => setApiKey(""),
|
||||
});
|
||||
return <div className="mx-auto w-full min-w-0 max-w-xl space-y-4">
|
||||
<label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(connectionId)} /></label>
|
||||
{save.error && <p role="alert" className="text-sm text-destructive">{save.error.message}</p>}
|
||||
<ProviderApiKeyCard providerName="OpenRouter" value={apiKey} onChange={setApiKey} onSubmit={() => save.mutate()} disabled={save.isPending} placeholder="Enter API key here" autoFocus />
|
||||
<div className="flex justify-between gap-2"><Button variant="ghost" onClick={onCancel}>Cancel</Button><Button disabled={!name.trim() || !apiKey.trim() || save.isPending} onClick={() => save.mutate()}>{save.isPending ? "Connecting…" : "Connect"}</Button></div>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { useState } from "react";
|
||||
import { AiConnectionPicker } from "./AiConnectionPicker";
|
||||
import { ProviderApiKeyCard } from "@/components/AdapterLoginChrome";
|
||||
import type {
|
||||
AiConnectionBinding,
|
||||
AiConnectionRequirement,
|
||||
AiConnectionSummary,
|
||||
} from "./model";
|
||||
|
||||
const requirement: AiConnectionRequirement = {
|
||||
companyId: "design-example",
|
||||
provider: "anthropic",
|
||||
method: "subscription",
|
||||
};
|
||||
const account: AiConnectionSummary = {
|
||||
...requirement,
|
||||
id: "example",
|
||||
grantId: "example-grant",
|
||||
name: "My Claude subscription",
|
||||
ownership: "personal",
|
||||
ownerUserId: "example-user",
|
||||
ownerName: "You",
|
||||
status: "connected",
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
export function AiConnectionDesignExamples() {
|
||||
const [binding, setBinding] = useState<AiConnectionBinding>({
|
||||
provider: "anthropic",
|
||||
method: "subscription",
|
||||
mode: "responsible_user",
|
||||
});
|
||||
return (
|
||||
<div className="flex max-w-2xl flex-col gap-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shared AI connection identity, account selection, and existing
|
||||
authentication chrome. The full interactive state matrix lives in
|
||||
Storybook under AI Connections / Review. Example controls below do not
|
||||
connect accounts.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Provider lists and account management use Browse and AppDetail from the Connectors interface. The picker below uses ConnectionChoiceList, also used by ConnectionSetupFlow.</p>
|
||||
<AiConnectionPicker
|
||||
requirement={requirement}
|
||||
connections={[account]}
|
||||
value={binding}
|
||||
currentUserId="example-user"
|
||||
agentId="example-agent"
|
||||
agentName="Nova"
|
||||
onChange={setBinding}
|
||||
readOnly
|
||||
onConnect={() => {}}
|
||||
/>
|
||||
<ProviderApiKeyCard
|
||||
providerName="OpenAI"
|
||||
value=""
|
||||
disabled
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Enter API key here"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
aiConnectionBindingSchema,
|
||||
isAiConnectionCompatible,
|
||||
type AiConnectionBinding,
|
||||
type AiAuthMethod,
|
||||
type AiProvider,
|
||||
} from "@paperclipai/shared";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { AiConnectionPicker } from "./AiConnectionPicker";
|
||||
import { AiConnectionLegacyNotice } from "./AiConnectionManagement";
|
||||
import { AiConnectionCredentialStep } from "./AiConnectionCredentialStep";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function aiProviderForAdapter(
|
||||
adapterType: string,
|
||||
): AiProvider | undefined {
|
||||
return (
|
||||
{
|
||||
claude_local: "anthropic",
|
||||
codex_local: "openai",
|
||||
opencode_local: "openrouter",
|
||||
grok_local: "xai",
|
||||
} as Record<string, AiProvider>
|
||||
)[adapterType];
|
||||
}
|
||||
export function AiConnectionField({
|
||||
companyId,
|
||||
agentId,
|
||||
agentName,
|
||||
adapterType,
|
||||
model,
|
||||
value,
|
||||
onChange,
|
||||
environmentId,
|
||||
legacy = false,
|
||||
readOnly = false,
|
||||
}: {
|
||||
companyId: string;
|
||||
agentId?: string;
|
||||
agentName: string;
|
||||
adapterType: string;
|
||||
model?: string;
|
||||
value?: AiConnectionBinding;
|
||||
onChange: (binding: AiConnectionBinding) => void;
|
||||
environmentId?: string;
|
||||
legacy?: boolean;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const provider = aiProviderForAdapter(adapterType);
|
||||
const returnFocus = useRef<HTMLElement | null>(null);
|
||||
const restoreFocus = (event: Event) => { event.preventDefault(); returnFocus.current?.focus(); };
|
||||
const [adopting, setAdopting] = useState(false);
|
||||
const [pendingAdoption, setPendingAdoption] = useState<AiConnectionBinding>();
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const method: AiAuthMethod =
|
||||
value?.method ?? (provider === "openrouter" ? "api_key" : "subscription");
|
||||
const changeBinding = (next: AiConnectionBinding) => {
|
||||
if (legacy && !value) { if (!connecting) returnFocus.current = document.activeElement as HTMLElement; setPendingAdoption(next); }
|
||||
else onChange(next);
|
||||
};
|
||||
const client = useQueryClient();
|
||||
const accounts = useQuery({
|
||||
queryKey: ["ai-connections", companyId, agentId],
|
||||
queryFn: () => aiConnectionsApi.list(companyId, agentId),
|
||||
enabled: Boolean(provider),
|
||||
});
|
||||
if (!provider) return null;
|
||||
if (legacy && !value && !adopting)
|
||||
return (
|
||||
<AiConnectionLegacyNotice
|
||||
readOnly={readOnly}
|
||||
onAdopt={() => setAdopting(true)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{value && (adapterType !== "opencode_local" || Boolean(model)) && !isAiConnectionCompatible(value, adapterType, model) && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
This connection does not support the current harness and model. Choose
|
||||
a compatible connection before saving.
|
||||
</p>
|
||||
)}
|
||||
<AiConnectionPicker
|
||||
requirement={{ companyId, provider, method }}
|
||||
connections={accounts.data?.connections ?? []}
|
||||
value={value}
|
||||
currentUserId={accounts.data?.currentUserId ?? ""}
|
||||
agentId={agentId ?? ""}
|
||||
agentName={agentName}
|
||||
readOnly={readOnly}
|
||||
loading={accounts.isPending}
|
||||
error={accounts.error?.message}
|
||||
onChange={(binding) =>
|
||||
changeBinding(aiConnectionBindingSchema.parse(binding))
|
||||
}
|
||||
onConnect={() => { returnFocus.current = document.activeElement as HTMLElement; setConnecting(true); }}
|
||||
onRetry={() => void accounts.refetch()}
|
||||
/>
|
||||
<Dialog
|
||||
open={Boolean(pendingAdoption)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingAdoption(undefined);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-2xl" onCloseAutoFocus={restoreFocus}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adopt Connections for {agentName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Saving tests this account in {agentName}’s environment before
|
||||
replacing its existing authentication. Other agents keep their
|
||||
current configuration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">
|
||||
{pendingAdoption?.mode === "responsible_user"
|
||||
? `Responsible user’s default. For you: ${accounts.data?.connections.find((account) => account.isDefault && account.provider === provider && account.method === pendingAdoption.method)?.name ?? "Not connected"}. Other users use their own default.`
|
||||
: accounts.data?.connections.find(
|
||||
(account) => account.id === pendingAdoption?.connectionId,
|
||||
)?.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
After adoption, missing credentials block execution. Previous
|
||||
authentication will not be used as a fallback.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setPendingAdoption(undefined)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (pendingAdoption) onChange(pendingAdoption);
|
||||
setPendingAdoption(undefined);
|
||||
}}
|
||||
>
|
||||
Use this binding when saved
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={connecting} onOpenChange={setConnecting}>
|
||||
<DialogContent className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-2xl" onCloseAutoFocus={restoreFocus}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect account</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AiConnectionCredentialStep
|
||||
companyId={companyId}
|
||||
provider={provider}
|
||||
initialMethod={method}
|
||||
name={`My ${provider === "anthropic" ? "Claude" : provider === "openai" ? "OpenAI" : provider === "xai" ? "Grok" : "OpenRouter"} ${method === "subscription" ? "subscription" : "API"}`}
|
||||
ownership="personal"
|
||||
agentIds={agentId ? [agentId] : []}
|
||||
allAgents={false}
|
||||
environmentId={environmentId}
|
||||
onCancel={() => setConnecting(false)}
|
||||
onComplete={({ method: connectedMethod }) => {
|
||||
void client.invalidateQueries({
|
||||
queryKey: ["ai-connections", companyId],
|
||||
});
|
||||
setConnecting(false);
|
||||
changeBinding({ provider, method: connectedMethod, mode: "responsible_user" });
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Building2, UserRound } from "lucide-react";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
aiMethodLabel,
|
||||
type AiConnectionSummary,
|
||||
} from "./model";
|
||||
|
||||
export function AiConnectionIdentity({
|
||||
connection,
|
||||
}: {
|
||||
connection: AiConnectionSummary;
|
||||
}) {
|
||||
const provider = AI_PROVIDERS[connection.provider];
|
||||
const Icon = connection.ownership === "shared" ? Building2 : UserRound;
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<AppLogo name={provider.name} brandKey={connection.provider} logoUrl={provider.logo} size={24} />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="break-words text-sm font-medium">
|
||||
{connection.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{provider.name} ·{" "}
|
||||
{aiMethodLabel(connection.provider, connection.method)}
|
||||
{connection.accountLabel ? ` · ${connection.accountLabel}` : ""}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Icon aria-hidden className="size-3" />
|
||||
{connection.ownership === "shared"
|
||||
? "Company shared"
|
||||
: `Personal · ${connection.ownerName ?? "Account owner"}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function AiConnectionLegacyNotice({
|
||||
onAdopt,
|
||||
readOnly = false,
|
||||
}: {
|
||||
onAdopt: () => void;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
||||
<h3 className="text-sm font-semibold">
|
||||
Existing authentication — not managed by Connections
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This agent keeps its current authentication until you choose and test a
|
||||
managed connection. Confirm the account and who may use it before
|
||||
adopting.
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<Button variant="outline" className="self-start" onClick={onAdopt}>
|
||||
Choose a managed connection
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { ConnectionChoiceList } from "@/features/connections/ConnectionChoiceList";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
aiConnectionProblem,
|
||||
aiMethodLabel,
|
||||
bindingProblem,
|
||||
matchesAiRequirement,
|
||||
personalAiDefault,
|
||||
type AiConnectionBinding,
|
||||
type AiConnectionRequirement,
|
||||
type AiConnectionSummary,
|
||||
} from "./model";
|
||||
|
||||
export interface AiConnectionPickerProps {
|
||||
requirement: AiConnectionRequirement;
|
||||
connections: AiConnectionSummary[];
|
||||
value?: AiConnectionBinding;
|
||||
currentUserId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
readOnly?: boolean;
|
||||
onChange: (binding: AiConnectionBinding) => void;
|
||||
onConnect: () => void;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function AiConnectionPicker({
|
||||
requirement,
|
||||
connections,
|
||||
value,
|
||||
currentUserId,
|
||||
agentId,
|
||||
loading,
|
||||
error,
|
||||
readOnly,
|
||||
onChange,
|
||||
onConnect,
|
||||
onRetry,
|
||||
}: AiConnectionPickerProps) {
|
||||
const compatible = connections.filter((connection) =>
|
||||
matchesAiRequirement(connection, requirement),
|
||||
);
|
||||
const personalDefault = personalAiDefault(
|
||||
compatible,
|
||||
requirement,
|
||||
currentUserId,
|
||||
);
|
||||
const problem = value ? bindingProblem(
|
||||
value,
|
||||
requirement,
|
||||
compatible,
|
||||
currentUserId,
|
||||
agentId,
|
||||
) : undefined;
|
||||
const select = (
|
||||
mode: "shared",
|
||||
connection: AiConnectionSummary,
|
||||
) =>
|
||||
onChange({
|
||||
provider: requirement.provider,
|
||||
method: requirement.method,
|
||||
mode,
|
||||
connectionId: connection.id,
|
||||
grantId: connection.grantId,
|
||||
});
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-label="AI connection">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppLogo
|
||||
name={AI_PROVIDERS[requirement.provider].name}
|
||||
brandKey={requirement.provider}
|
||||
logoUrl={AI_PROVIDERS[requirement.provider].logo}
|
||||
darkLogoUrl={requirement.provider === "xai" ? "/brands/adapters/grok-dark.svg" : undefined}
|
||||
size={32}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h3 className="text-sm font-semibold">AI connection</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{AI_PROVIDERS[requirement.provider].name} ·{" "}
|
||||
{aiMethodLabel(requirement.provider, requirement.method)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div role="status" aria-label="Loading AI connections">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
{onRetry && (
|
||||
<Button type="button" variant="outline" onClick={onRetry}>
|
||||
Retry connections
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ConnectionChoiceList
|
||||
disabled={readOnly}
|
||||
selectedId={value?.mode === "responsible_user" ? "responsible_user" : value?.connectionId}
|
||||
choices={[
|
||||
{ id: "responsible_user", name: "Responsible user’s connection", description: <>
|
||||
<span className="block">For you: {personalDefault?.name ?? "Not connected"}</span>
|
||||
<span className="block">Other users’ tasks use their own {requirement.method === "api_key" ? `${AI_PROVIDERS[requirement.provider].name} API key` : aiMethodLabel(requirement.provider, requirement.method)}.</span>
|
||||
</> },
|
||||
...compatible.filter((connection) => connection.ownership === "shared").map((connection) => ({
|
||||
id: connection.id, name: connection.name,
|
||||
disabled: Boolean(aiConnectionProblem(connection)),
|
||||
description: <>Company shared{connection.accountLabel ? ` · ${connection.accountLabel}` : ""}{aiConnectionProblem(connection) ? ` · ${aiConnectionProblem(connection)}` : ""}</>,
|
||||
})),
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
if (id === "responsible_user") onChange({provider: requirement.provider, method: requirement.method, mode: "responsible_user"});
|
||||
else { const connection = compatible.find((item) => item.id === id)!; select("shared", connection); }
|
||||
}}
|
||||
/>
|
||||
{problem && (
|
||||
<p role="status" className="text-sm text-destructive">
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="self-end"
|
||||
onClick={onConnect}
|
||||
>
|
||||
Connect another account
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { heartbeatsApi } from "@/api/heartbeats";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { AiConnectionAccountControls } from "./AiConnectionAccountControls";
|
||||
import type { ToolConnection } from "@paperclipai/shared";
|
||||
import { aiMethodLabel } from "./model";
|
||||
|
||||
export function ManagedAiConnectionRow({
|
||||
connection,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
}) {
|
||||
const metadata = connection.config?.ai as
|
||||
| {
|
||||
provider: "anthropic" | "openai" | "openrouter" | "xai";
|
||||
method: "subscription" | "api_key";
|
||||
}
|
||||
| undefined;
|
||||
if (!metadata) return null;
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{aiMethodLabel(metadata.provider, metadata.method)} ·{" "}
|
||||
{connection.credentialPolicy === "per_user"
|
||||
? "Personal"
|
||||
: "Company shared"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
export function ManagedAiConnectionDetails({
|
||||
connection,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const runs = useQuery({
|
||||
queryKey: ["ai-connection-active-runs", connection.id],
|
||||
queryFn: () =>
|
||||
aiConnectionsApi.activeRuns(connection.companyId, connection.id),
|
||||
});
|
||||
const accounts = useQuery({
|
||||
queryKey: ["ai-connections", connection.companyId],
|
||||
queryFn: () => aiConnectionsApi.list(connection.companyId),
|
||||
});
|
||||
const grants = useQuery({
|
||||
queryKey: ["ai-connection-grants", connection.id],
|
||||
queryFn: () => toolsApi.listConnectionGrants(connection.id),
|
||||
});
|
||||
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),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const stop = useMutation({
|
||||
mutationFn: (id: string) => heartbeatsApi.cancel(id),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const account = accounts.data?.connections.find(
|
||||
(a) => a.id === connection.id,
|
||||
);
|
||||
const grant = grants.data?.grants.find((g) => g.id === account?.grantId);
|
||||
const error =
|
||||
accounts.error ??
|
||||
grants.error ??
|
||||
makeDefault.error ??
|
||||
stop.error;
|
||||
if (error)
|
||||
return (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error.message}
|
||||
</p>
|
||||
);
|
||||
if (!account || !grant)
|
||||
return (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{accounts.isPending || grants.isPending
|
||||
? "Loading AI account…"
|
||||
: "This account is not available to you."}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AiConnectionAccountControls
|
||||
account={account}
|
||||
grant={grant}
|
||||
currentUserId={accounts.data!.currentUserId}
|
||||
onMakeDefault={() => makeDefault.mutate(grant.id)}
|
||||
onRevoke={() => revoke.mutateAsync(grant.id).then(() => undefined)}
|
||||
revocationDetails={
|
||||
<div className="space-y-2">
|
||||
{runs.error && (
|
||||
<p role="alert">
|
||||
Could not load active runs. Retry before revoking.
|
||||
</p>
|
||||
)}
|
||||
{runs.data?.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="flex items-center justify-between gap-3 text-sm"
|
||||
>
|
||||
<span>
|
||||
{run.agentName} · {run.status}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={stop.isPending}
|
||||
onClick={() => stop.mutate(run.id)}
|
||||
>
|
||||
Stop run
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
onReconnect={() =>
|
||||
navigate(
|
||||
`/apps/connect?source=${account.provider}&reconnect=${connection.id}&method=ai-${account.method}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
aiConnectionProblem,
|
||||
bindingProblem,
|
||||
matchesAiRequirement,
|
||||
personalAiDefault,
|
||||
type AiConnectionSummary,
|
||||
type AiConnectionRequirement,
|
||||
type AiConnectionBinding,
|
||||
} from "./model";
|
||||
|
||||
const requirement: AiConnectionRequirement = {
|
||||
companyId: "company",
|
||||
provider: "anthropic",
|
||||
method: "subscription",
|
||||
};
|
||||
const account: AiConnectionSummary = {
|
||||
...requirement,
|
||||
id: "connection",
|
||||
grantId: "grant",
|
||||
name: "Personal Claude",
|
||||
ownership: "personal",
|
||||
ownerUserId: "alice",
|
||||
status: "connected",
|
||||
isDefault: true,
|
||||
};
|
||||
const binding: AiConnectionBinding = {
|
||||
provider: "anthropic",
|
||||
method: "subscription",
|
||||
mode: "responsible_user",
|
||||
};
|
||||
|
||||
describe("AI connection selection presentation", () => {
|
||||
it("scopes personal defaults to company, user, provider and method", () => {
|
||||
for (const change of [
|
||||
{ companyId: "other" },
|
||||
{ provider: "openai" as const },
|
||||
{ method: "api_key" as const },
|
||||
{ ownerUserId: "bob" },
|
||||
{ ownership: "shared" as const },
|
||||
]) {
|
||||
expect(
|
||||
personalAiDefault([{ ...account, ...change }], requirement, "alice"),
|
||||
).toBeUndefined();
|
||||
}
|
||||
expect(personalAiDefault([account], requirement, "alice")).toBe(account);
|
||||
});
|
||||
it("retains a revoked default instead of falling back to a healthy account", () => {
|
||||
const revoked = { ...account, status: "revoked" as const };
|
||||
const alternate = { ...account, id: "alternate", isDefault: false };
|
||||
expect(personalAiDefault([alternate, revoked], requirement, "alice")).toBe(
|
||||
revoked,
|
||||
);
|
||||
expect(
|
||||
bindingProblem(
|
||||
binding,
|
||||
requirement,
|
||||
[alternate, revoked],
|
||||
"alice",
|
||||
"agent",
|
||||
),
|
||||
).toContain("Revoked");
|
||||
});
|
||||
it("does not select another user’s account", () => {
|
||||
expect(
|
||||
bindingProblem(binding, requirement, [account], "bob", "agent"),
|
||||
).toContain("No connection");
|
||||
});
|
||||
it("does not infer a default from the first compatible connection", () => {
|
||||
expect(
|
||||
personalAiDefault(
|
||||
[{ ...account, isDefault: false }],
|
||||
requirement,
|
||||
"alice",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it("rejects incompatible bindings without modifying the requirement", () => {
|
||||
const original = { ...requirement };
|
||||
expect(
|
||||
bindingProblem(
|
||||
{ ...binding, provider: "openai" },
|
||||
requirement,
|
||||
[account],
|
||||
"alice",
|
||||
"agent",
|
||||
),
|
||||
).toContain("compatible");
|
||||
expect(requirement).toEqual(original);
|
||||
expect(
|
||||
matchesAiRequirement({ ...account, method: "api_key" }, requirement),
|
||||
).toBe(false);
|
||||
});
|
||||
it("requires exact grant identity and human access for a legacy personal selection", () => {
|
||||
const delegated = {
|
||||
provider: "anthropic",
|
||||
method: "subscription",
|
||||
mode: "delegated",
|
||||
connectionId: account.id,
|
||||
grantId: account.grantId,
|
||||
} as const;
|
||||
expect(
|
||||
bindingProblem(delegated, requirement, [account], "bob", "agent"),
|
||||
).toContain("not shared with you");
|
||||
expect(
|
||||
bindingProblem(
|
||||
delegated,
|
||||
requirement,
|
||||
[account],
|
||||
"alice",
|
||||
"agent",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
bindingProblem(
|
||||
{ ...delegated, grantId: "different" },
|
||||
requirement,
|
||||
[account],
|
||||
"alice",
|
||||
"agent",
|
||||
),
|
||||
).toContain("no longer available");
|
||||
});
|
||||
it("does not mistake a personal account for shared", () => {
|
||||
expect(
|
||||
bindingProblem(
|
||||
{
|
||||
...binding,
|
||||
mode: "shared",
|
||||
connectionId: account.id,
|
||||
grantId: account.grantId,
|
||||
},
|
||||
requirement,
|
||||
[account],
|
||||
"alice",
|
||||
"agent",
|
||||
),
|
||||
).toContain("company-shared");
|
||||
});
|
||||
it("preserves server-projected eligibility denials", () => {
|
||||
expect(
|
||||
aiConnectionProblem({
|
||||
...account,
|
||||
unavailableReason: "Not in the shared audience",
|
||||
}),
|
||||
).toBe("Not in the shared audience");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/** Redacted presentation contracts shared with the production API. */
|
||||
import type { AiProvider, AiAuthMethod, AiManagedConnectionSummary, AiConnectionBinding } from "@paperclipai/shared";
|
||||
export type { AiProvider, AiAuthMethod, AiConnectionBinding } from "@paperclipai/shared";
|
||||
export type AiConnectionStatus = AiManagedConnectionSummary["status"];
|
||||
|
||||
export const AI_PROVIDERS: Record<
|
||||
AiProvider,
|
||||
{ name: string; subscriptionName?: string; logo?: string }
|
||||
> = {
|
||||
anthropic: {
|
||||
name: "Claude",
|
||||
subscriptionName: "Claude subscription",
|
||||
logo: "/brands/claude-color.svg",
|
||||
},
|
||||
openai: {
|
||||
name: "OpenAI",
|
||||
subscriptionName: "ChatGPT subscription",
|
||||
logo: "/brands/codex-color.svg",
|
||||
},
|
||||
openrouter: { name: "OpenRouter", logo: "/brands/apps/openrouter.svg" },
|
||||
xai: {
|
||||
name: "Grok",
|
||||
subscriptionName: "Grok subscription",
|
||||
logo: "/brands/adapters/grok.svg",
|
||||
},
|
||||
};
|
||||
|
||||
export type AiConnectionSummary = Omit<AiManagedConnectionSummary, "isDefault"> & { isDefault?: boolean };
|
||||
|
||||
export interface AiConnectionRequirement {
|
||||
companyId: string;
|
||||
provider: AiProvider;
|
||||
method: AiAuthMethod;
|
||||
}
|
||||
|
||||
export const AI_CONNECTION_STATUS: Record<AiConnectionStatus, string> = {
|
||||
connected: "Connected",
|
||||
needs_attention: "Needs attention",
|
||||
expired: "Expired",
|
||||
revoked: "Revoked",
|
||||
};
|
||||
|
||||
export function aiMethodLabel(provider: AiProvider, method: AiAuthMethod) {
|
||||
return method === "subscription"
|
||||
? (AI_PROVIDERS[provider].subscriptionName ?? "Subscription unavailable")
|
||||
: "API key";
|
||||
}
|
||||
|
||||
export function matchesAiRequirement(
|
||||
connection: AiConnectionSummary,
|
||||
requirement: AiConnectionRequirement,
|
||||
) {
|
||||
return (
|
||||
connection.companyId === requirement.companyId &&
|
||||
connection.provider === requirement.provider &&
|
||||
connection.method === requirement.method
|
||||
);
|
||||
}
|
||||
|
||||
export function personalAiDefault(
|
||||
connections: AiConnectionSummary[],
|
||||
requirement: AiConnectionRequirement,
|
||||
userId: string,
|
||||
) {
|
||||
// Never choose another account because the declared default is unhealthy.
|
||||
return connections.find(
|
||||
(connection) =>
|
||||
matchesAiRequirement(connection, requirement) &&
|
||||
connection.ownership === "personal" &&
|
||||
connection.ownerUserId === userId &&
|
||||
connection.isDefault,
|
||||
);
|
||||
}
|
||||
|
||||
export function aiConnectionProblem(connection?: AiConnectionSummary) {
|
||||
if (!connection)
|
||||
return "No connection selected. Connect an account to continue.";
|
||||
return (
|
||||
connection.unavailableReason ??
|
||||
(connection.status === "connected"
|
||||
? null
|
||||
: `${AI_CONNECTION_STATUS[connection.status]}. Reconnect this account to continue.`)
|
||||
);
|
||||
}
|
||||
|
||||
export function bindingProblem(
|
||||
binding: AiConnectionBinding,
|
||||
requirement: AiConnectionRequirement,
|
||||
connections: AiConnectionSummary[],
|
||||
userId: string,
|
||||
_agentId: string,
|
||||
) {
|
||||
if (
|
||||
binding.provider !== requirement.provider ||
|
||||
binding.method !== requirement.method
|
||||
)
|
||||
return "Choose a connection compatible with this provider and sign-in method.";
|
||||
if (binding.mode === "responsible_user")
|
||||
return aiConnectionProblem(
|
||||
personalAiDefault(connections, requirement, userId),
|
||||
);
|
||||
const connection = connections.find(
|
||||
(item) =>
|
||||
item.id === binding.connectionId &&
|
||||
item.grantId === binding.grantId &&
|
||||
matchesAiRequirement(item, requirement),
|
||||
);
|
||||
if (!connection)
|
||||
return "This connection is no longer available for this agent. Choose another connection.";
|
||||
if (binding.mode === "shared" && connection.ownership !== "shared")
|
||||
return "Choose a company-shared connection.";
|
||||
if (
|
||||
binding.mode === "delegated" &&
|
||||
(connection.ownership !== "personal" ||
|
||||
connection.ownerUserId !== userId)
|
||||
)
|
||||
return "This credential is not shared with you. Choose a connection you can use.";
|
||||
return aiConnectionProblem(connection);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
// @vitest-environment jsdom
|
||||
import { StrictMode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { useLocalAiLogin } from "./useLocalAiLogin";
|
||||
import { LocalProviderLoginInstructions } from "../AdapterLoginChrome";
|
||||
|
||||
const api = vi.hoisted(() => ({ startLocalLogin: vi.fn(), checkLocalLogin: vi.fn(), cancelLocalLogin: vi.fn(), connectLocal: vi.fn() }));
|
||||
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: api }));
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
let host: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
api.startLocalLogin.mockImplementation(async () => ({ sessionId: "attempt-1", command: "isolated codex login", expiresAt: "2099-01-01T00:00:00Z" }));
|
||||
api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required" });
|
||||
api.cancelLocalLogin.mockResolvedValue({});
|
||||
api.connectLocal.mockResolvedValue({ connectionId: "connection", grantId: "grant" });
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
});
|
||||
afterEach(() => { flushSync(() => root.unmount()); host.remove(); });
|
||||
function Harness({ name = "Account", provider = "openai", enabled = true }: { name?: string; provider?: "anthropic" | "openai"; enabled?: boolean }) {
|
||||
const login = useLocalAiLogin("company", { provider, method: "subscription", name, ownership: "personal", agentIds: [], allAgents: true }, enabled);
|
||||
return <><LocalProviderLoginInstructions adapterType={provider === "anthropic" ? "claude_local" : "codex_local"} login={login} /><button onClick={() => void login.connect()}>Connect</button></>;
|
||||
}
|
||||
it("checks once under StrictMode, preserves renaming and navigation, and cancels only on explicit retry", async () => {
|
||||
flushSync(() => root.render(<StrictMode><Harness name="First name" /></StrictMode>));
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
|
||||
expect(api.startLocalLogin).toHaveBeenCalledTimes(1);
|
||||
expect(api.checkLocalLogin).toHaveBeenCalledTimes(1);
|
||||
expect(api.cancelLocalLogin).not.toHaveBeenCalled();
|
||||
flushSync(() => root.render(<StrictMode><Harness name="Renamed" /></StrictMode>));
|
||||
expect(api.startLocalLogin).toHaveBeenCalledTimes(1);
|
||||
flushSync(() => root.render(<StrictMode><Harness name="Renamed" enabled={false} /></StrictMode>));
|
||||
flushSync(() => root.render(<StrictMode><Harness name="Renamed" /></StrictMode>));
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
|
||||
expect(api.cancelLocalLogin).not.toHaveBeenCalled();
|
||||
flushSync(() => Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Connect')!.click());
|
||||
await vi.waitFor(() => expect(api.connectLocal).toHaveBeenCalledWith("company", expect.objectContaining({ name: "Renamed", localSessionId: "attempt-1" })));
|
||||
flushSync(() => Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Start sign-in again')!.click());
|
||||
await vi.waitFor(() => expect(api.startLocalLogin).toHaveBeenCalledTimes(2));
|
||||
expect(api.cancelLocalLogin).toHaveBeenCalledTimes(1);
|
||||
expect(api.cancelLocalLogin.mock.invocationCallOrder[0]).toBeLessThan(api.startLocalLogin.mock.invocationCallOrder[1]);
|
||||
});
|
||||
it.each(["anthropic", "openai"] as const)("detects an already-signed-in %s account before showing instructions, and does not save it until Connect", async provider => {
|
||||
api.checkLocalLogin.mockResolvedValue({ status: "ready" });
|
||||
flushSync(() => root.render(<Harness provider={provider} />));
|
||||
expect(host.textContent).toContain("Checking local");
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
|
||||
expect(host.textContent).not.toContain("Run this in a terminal");
|
||||
expect(api.connectLocal).not.toHaveBeenCalled();
|
||||
if (provider === "anthropic") expect(api.startLocalLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
it("detects terminal completion on focus without needing a Connect attempt", async () => {
|
||||
flushSync(() => root.render(<Harness />));
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
|
||||
api.checkLocalLogin.mockResolvedValue({ status: "ready" });
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
|
||||
expect(host.textContent).not.toContain("isolated codex login");
|
||||
expect(api.connectLocal).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps a copied command's attempt alive after leaving the page", async () => {
|
||||
flushSync(() => root.render(<Harness />));
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
|
||||
flushSync(() => root.render(<div>Another page</div>));
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(api.cancelLocalLogin).not.toHaveBeenCalled();
|
||||
const checks = api.checkLocalLogin.mock.calls.length;
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
expect(api.checkLocalLogin).toHaveBeenCalledTimes(checks);
|
||||
});
|
||||
|
||||
it("explicit retry can replace an attempt opened in another authentication host", async () => {
|
||||
api.startLocalLogin.mockRejectedValueOnce(new Error("Another sign-in is still open."));
|
||||
flushSync(() => root.render(<Harness />));
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("Another sign-in"));
|
||||
flushSync(() => Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Start sign-in again')!.click());
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
|
||||
expect(api.startLocalLogin).toHaveBeenLastCalledWith("company", expect.objectContaining({ restart: true }));
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus } from "@paperclipai/shared";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
|
||||
/** Every authentication host uses the same local credential check and login lifecycle. */
|
||||
export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLoginIntent, enabled: boolean) {
|
||||
const isolated = intent.provider === "openai" || intent.provider === "xai";
|
||||
const active = Boolean(companyId && enabled);
|
||||
const [attempt, setAttempt] = useState<LocalAiLoginAttempt | null>(null);
|
||||
const [status, setStatus] = useState<LocalAiLoginStatus["status"] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const latestIntent = useRef(intent);
|
||||
const restartRequested = useRef(false);
|
||||
const pending = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const current = useRef<{ key: string; companyId: string; request: Promise<LocalAiLoginAttempt> } | null>(null);
|
||||
function cancelCurrent() {
|
||||
const previous = current.current;
|
||||
current.current = null;
|
||||
if (previous) pending.current = previous.request
|
||||
.then((result) => aiConnectionsApi.cancelLocalLogin(previous.companyId, result.sessionId)).catch(() => {});
|
||||
}
|
||||
latestIntent.current = intent;
|
||||
// Renaming the account does not restart sign-in; access/target changes do.
|
||||
const target = JSON.stringify({ ...intent, name: undefined });
|
||||
useEffect(() => {
|
||||
setAttempt(null);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
if (!active || !companyId) return;
|
||||
let cancelled = false;
|
||||
let checking = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const key = JSON.stringify([companyId, target, generation]);
|
||||
if (isolated && current.current?.key !== key) {
|
||||
cancelCurrent();
|
||||
const input = { ...latestIntent.current, ...(restartRequested.current ? { restart: true } : {}) };
|
||||
restartRequested.current = false;
|
||||
const request = pending.current.then(() => aiConnectionsApi.startLocalLogin(companyId, input));
|
||||
current.current = { key, companyId, request };
|
||||
pending.current = request.catch(() => {});
|
||||
}
|
||||
const request = isolated ? current.current!.request : Promise.resolve(null);
|
||||
async function check() {
|
||||
if (checking || cancelled) return;
|
||||
checking = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const result = await request;
|
||||
if (cancelled) return;
|
||||
setAttempt(result);
|
||||
const next = await aiConnectionsApi.checkLocalLogin(companyId!, {
|
||||
...latestIntent.current, ...(result ? { localSessionId: result.sessionId } : {}),
|
||||
});
|
||||
if (cancelled) return;
|
||||
setStatus(next.status);
|
||||
setError(next.status === "expired" ? "This sign-in attempt expired. Start sign-in again." : null);
|
||||
// Stop polling a verified account. Focus still rechecks after a terminal
|
||||
// visit; awaiting terminal login never requires repeated Connect clicks.
|
||||
if (next.status === "sign_in_required") timer = setTimeout(() => void check(), 5000);
|
||||
} catch (cause) {
|
||||
if (!cancelled) setError(cause instanceof Error ? cause.message : "Could not check local sign-in.");
|
||||
} finally { checking = false; }
|
||||
}
|
||||
const onFocus = () => { if (!document.hidden) void check(); };
|
||||
void check();
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onFocus);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onFocus);
|
||||
// Navigation is not cancellation. The server resumes this bounded attempt
|
||||
// when the user returns and reaps abandoned attempts after expiry. Deleting
|
||||
// here made copied CODEX_HOME commands point at nonexistent directories.
|
||||
};
|
||||
}, [companyId, active, isolated, target, generation]);
|
||||
return {
|
||||
command: attempt?.command,
|
||||
status,
|
||||
preparing: active && !status && !error,
|
||||
error,
|
||||
retry: () => { restartRequested.current = true; cancelCurrent(); setGeneration((value) => value + 1); },
|
||||
connect: (input = intent) => {
|
||||
if (!companyId) throw new Error("Choose a company before connecting.");
|
||||
if (isolated && !attempt) throw new Error("Prepare local sign-in before connecting.");
|
||||
return aiConnectionsApi.connectLocal(companyId, { ...input, ...(attempt ? { localSessionId: attempt.sessionId } : {}) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -10,7 +10,18 @@ const mocks = vi.hoisted(() => ({
|
|||
login: vi.fn(),
|
||||
personal: vi.fn(),
|
||||
organization: vi.fn(),
|
||||
loginPanel: vi.fn(),
|
||||
}));
|
||||
const managedApi = vi.hoisted(() => ({
|
||||
list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })),
|
||||
loginResult: vi.fn(async () => ({ connectionId: "login-account", grantId: "login-grant" })),
|
||||
connectLocal: vi.fn(async () => ({ connectionId: "local-account", grantId: "local-grant" })),
|
||||
startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", command: "CODEX_HOME='/fixture/isolated-login' codex login", expiresAt: "2026-09-11T20:00:00Z" })),
|
||||
checkLocalLogin: vi.fn(async () => ({ status: "sign_in_required" as const })),
|
||||
cancelLocalLogin: vi.fn(async () => ({})),
|
||||
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
|
||||
}));
|
||||
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi }));
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: {
|
||||
getAdapterAuthSignal: mocks.auth,
|
||||
|
|
@ -21,7 +32,7 @@ vi.mock("@/api/secrets", () => ({
|
|||
secretsApi: { listMyUserSecrets: mocks.personal, list: mocks.organization },
|
||||
}));
|
||||
vi.mock("../AgentConfigForm", () => ({
|
||||
AdapterLoginPanel: () => <div>New subscription login</div>,
|
||||
AdapterLoginPanel: (props: unknown) => { mocks.loginPanel(props); return <div>New subscription login</div>; },
|
||||
}));
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
|
@ -39,6 +50,8 @@ async function mount(
|
|||
codexSubscriptions = false,
|
||||
savedApiKeys = true,
|
||||
cachedClaudeLogin = false,
|
||||
managedAccount?: Parameters<typeof AgentProviderConnection>[0]["managedAccount"],
|
||||
localEnvironment = false,
|
||||
) {
|
||||
const key =
|
||||
adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
||||
|
|
@ -91,6 +104,8 @@ async function mount(
|
|||
client.setQueryData(["claude-oauth-token-status", "c1"], { secretId: "cached-claude", latestVersion: 1 });
|
||||
mocks.auth.mockResolvedValue({ status: "absent" });
|
||||
}
|
||||
client.setQueryData(["health"], { deploymentMode: "local_trusted" });
|
||||
client.setQueryDefaults(["health"], { staleTime: Infinity });
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
|
|
@ -104,16 +119,18 @@ async function mount(
|
|||
adapterType={adapterType}
|
||||
environmentId="e1"
|
||||
canLogin={canLogin}
|
||||
localEnvironment={localEnvironment}
|
||||
onBack={() => {}}
|
||||
testConnection={test}
|
||||
onConnected={connected}
|
||||
managedAccount={managedAccount}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
await vi.waitFor(() => expect(mocks.personal).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(client.isFetching()).toBe(0));
|
||||
if (savedApiKeys) await vi.waitFor(() => expect(host.textContent).toContain("2 saved API keys"));
|
||||
if (savedApiKeys && !managedAccount) await vi.waitFor(() => expect(host.textContent).toContain("2 saved API keys"));
|
||||
return { test, connected, key };
|
||||
}
|
||||
function click(text: string) {
|
||||
|
|
@ -129,6 +146,96 @@ function openProvider() {
|
|||
);
|
||||
}
|
||||
describe("AgentProviderConnection reuse", () => {
|
||||
it.each(["claude_local", "codex_local"] as const)("connects a local subscription without a sandbox and supports retry: %s", async (adapterType) => {
|
||||
const onComplete = vi.fn();
|
||||
const intent = { provider: adapterType === "claude_local" ? "anthropic" as const : "openai" as const, method: "subscription" as const, name: "My account", ownership: "personal" as const, agentIds: [], allAgents: false };
|
||||
await mount(adapterType, false, false, false, false, false, { intent, onComplete }, true);
|
||||
openProvider();
|
||||
await vi.waitFor(() => expect(host.textContent).toContain(adapterType === "claude_local" ? "claude auth login" : "codex login"));
|
||||
expect(host.textContent).toContain("machine running Paperclip");
|
||||
expect(host.textContent).not.toContain("sandbox");
|
||||
managedApi.connectLocal.mockRejectedValueOnce(new Error("Run local login and try again"));
|
||||
click("Connect");
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("Run local login and try again"));
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
if (adapterType === "codex_local") {
|
||||
click("Start sign-in again");
|
||||
await vi.waitFor(() => expect(host.textContent).not.toContain("Run local login and try again"));
|
||||
await vi.waitFor(() => expect(managedApi.cancelLocalLogin).toHaveBeenCalledWith("c1", "local-attempt"));
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("codex login"));
|
||||
}
|
||||
click("Connect");
|
||||
await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: "local-account", grantId: "local-grant", method: "subscription" }));
|
||||
expect(managedApi.connectLocal).toHaveBeenCalledWith("c1", adapterType === "codex_local" ? { ...intent, localSessionId: "local-attempt" } : intent);
|
||||
expect(mocks.loginPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
it("leaves a completed local account saved when its host is cancelled", async () => {
|
||||
let finish!: (result: { connectionId: string; grantId: string }) => void;
|
||||
managedApi.connectLocal.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
const onComplete = vi.fn();
|
||||
await mount("claude_local", false, false, false, false, false, { intent: { provider: "anthropic", method: "subscription", name: "My account", ownership: "personal", agentIds: [], allAgents: false }, onComplete }, true);
|
||||
openProvider(); click("Connect"); flushSync(() => root.unmount());
|
||||
finish({ connectionId: "saved", grantId: "saved-grant" });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["claude_local", "codex_local"] as const)("does not import local credentials for an unsupported remote environment: %s", async (adapterType) => {
|
||||
const onComplete = vi.fn();
|
||||
const intent = { provider: adapterType === "claude_local" ? "anthropic" as const : "openai" as const, method: "subscription" as const, name: "Engineering subscription", ownership: "shared" as const, agentIds: ["nova"], allAgents: false };
|
||||
const { test } = await mount(adapterType, false, false, false, true, false, { intent, onComplete });
|
||||
openProvider();
|
||||
expect(host.textContent).toContain("This environment does not support browser sign-in");
|
||||
expect(host.textContent).not.toContain("login on this machine");
|
||||
click("Connect");
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
expect(managedApi.create).not.toHaveBeenCalled();
|
||||
expect(test).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["claude_local", "codex_local"] as const)("drives onboarding's provider redirect and completion: %s", async (adapterType) => {
|
||||
const onComplete = vi.fn();
|
||||
const intent = { provider: adapterType === "claude_local" ? "anthropic" as const : "openai" as const, method: "subscription" as const, name: "My account", ownership: "personal" as const, agentIds: [], allAgents: false };
|
||||
await mount(adapterType, false, true, false, false, false, { intent, onComplete });
|
||||
openProvider();
|
||||
const panel = () => mocks.loginPanel.mock.calls.at(-1)![0];
|
||||
expect(panel().chrome).toBe("onboarding");
|
||||
expect(panel().autoStart).toBe(true);
|
||||
expect(panel().aiConnection).toEqual(intent);
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
try {
|
||||
flushSync(() => panel().onPromptReady("https://provider.example/authorize"));
|
||||
click(adapterType === "claude_local" ? "Sign in to Claude" : "Sign in to OpenAI");
|
||||
expect(open).toHaveBeenCalledWith("https://provider.example/authorize", "_blank", "noreferrer,noopener");
|
||||
expect(host.textContent).toContain("Waiting for code");
|
||||
flushSync(() => panel().onCodeSubmitted());
|
||||
expect(host.textContent).toContain("Connecting");
|
||||
flushSync(() => panel().onSubmitFailed());
|
||||
expect(host.textContent).toContain("Waiting for code");
|
||||
flushSync(() => panel().onConnected("session-1"));
|
||||
await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: "login-account", grantId: "login-grant", method: "subscription" }));
|
||||
expect(managedApi.loginResult).toHaveBeenCalledWith("c1", "session-1");
|
||||
} finally { open.mockRestore(); }
|
||||
});
|
||||
|
||||
it("does not advance after Back while the saved login result is loading", async () => {
|
||||
let finish!: (result: { connectionId: string; grantId: string }) => void;
|
||||
managedApi.loginResult.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
const onComplete = vi.fn();
|
||||
await mount("claude_local", false, true, false, false, false, { intent: { provider: "anthropic", method: "subscription", name: "My account", ownership: "personal", agentIds: [], allAgents: false }, onComplete });
|
||||
openProvider();
|
||||
flushSync(() => mocks.loginPanel.mock.calls.at(-1)![0].onConnected("session-1"));
|
||||
click("Back");
|
||||
finish({ connectionId: "saved", grantId: "grant" });
|
||||
await Promise.resolve();
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
it("starts the existing browser login when adding an account even if the environment is authenticated", async () => {
|
||||
await mount("claude_local", true, true, false, true, false, { intent: { provider: "anthropic", method: "subscription", name: "My second account", ownership: "personal", agentIds: [], allAgents: false }, onComplete: vi.fn() });
|
||||
openProvider();
|
||||
expect(host.textContent).toContain("New subscription login");
|
||||
expect(host.textContent).not.toContain("Use saved subscription");
|
||||
});
|
||||
|
||||
it("defaults to subscription when no saved credentials exist", async () => {
|
||||
await mount("claude_local", false, true, false, false);
|
||||
expect(host.textContent).toContain("Use API key instead");
|
||||
|
|
@ -241,4 +348,36 @@ describe("AgentProviderConnection reuse", () => {
|
|||
click("Connect");
|
||||
await vi.waitFor(() => expect(test).toHaveBeenCalledWith({ env: {} }));
|
||||
});
|
||||
it.each(["claude_local", "codex_local"] as const)("uses a managed subscription through the upstream chooser for %s", async (adapterType) => {
|
||||
const provider = adapterType === "claude_local" ? "anthropic" : "openai";
|
||||
managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{
|
||||
id: "account", grantId: "grant", companyId: "c1", provider,
|
||||
method: "subscription", name: "My subscription", ownership: "personal",
|
||||
ownerUserId: "user-1", isDefault: true, status: "connected",
|
||||
}] } as never);
|
||||
const { connected } = await mount(adapterType, false, true, false, false);
|
||||
openProvider();
|
||||
expect(host.querySelector('select[aria-label="Saved subscription"]')?.textContent).toContain("My subscription (Your default)");
|
||||
click("Use saved subscription");
|
||||
await vi.waitFor(() => expect(connected).toHaveBeenCalledWith({ env: {}, aiConnection: { provider, method: "subscription", mode: "responsible_user" } }));
|
||||
expect(managedApi.create).not.toHaveBeenCalled();
|
||||
flushSync(() => {
|
||||
const select = host.querySelector("select")!;
|
||||
select.value = "";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(host.textContent).toContain("New subscription login");
|
||||
await client.invalidateQueries();
|
||||
await vi.waitFor(() => expect(client.isFetching()).toBe(0));
|
||||
expect(host.querySelector("select")!.value).toBe("");
|
||||
expect(host.textContent).toContain("New subscription login");
|
||||
});
|
||||
it("never offers a saved Codex home in Claude's subscription chooser", async () => {
|
||||
await mount("claude_local", false, true, true);
|
||||
click("Use subscription instead");
|
||||
openProvider();
|
||||
expect(host.querySelector('select[aria-label="Saved subscription"]')).toBeNull();
|
||||
expect(host.textContent).not.toContain("ChatGPT account");
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import { healthApi } from "@/api/health";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { useLocalAiLogin } from "../ai-connections/useLocalAiLogin";
|
||||
import type { AiConnectionBinding, AiConnectionLoginIntent } from "@paperclipai/shared";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
|
|
@ -9,6 +13,7 @@ import { agentsApi } from "@/api/agents";
|
|||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AdapterLoginPanel } from "../AgentConfigForm";
|
||||
import {
|
||||
LocalProviderLoginInstructions,
|
||||
OnboardingCardField,
|
||||
OnboardingLoginCard,
|
||||
} from "../AdapterLoginChrome";
|
||||
|
|
@ -21,6 +26,7 @@ import type { EnvBinding } from "@paperclipai/shared";
|
|||
|
||||
export type ProviderConnection = {
|
||||
env: Record<string, EnvBinding>;
|
||||
aiConnection?: AiConnectionBinding;
|
||||
/** Kept in memory until the user finishes setup. */
|
||||
credentials?: Record<string, string>;
|
||||
storedSessionId?: string;
|
||||
|
|
@ -31,20 +37,33 @@ export function AgentProviderConnection({
|
|||
adapterType,
|
||||
environmentId,
|
||||
canLogin,
|
||||
localEnvironment = false,
|
||||
onConnected,
|
||||
onBack,
|
||||
testConnection,
|
||||
testError,
|
||||
managedAccount,
|
||||
}: {
|
||||
companyId: string;
|
||||
adapterType: "claude_local" | "codex_local";
|
||||
adapterType: "claude_local" | "codex_local" | "grok_local";
|
||||
environmentId: string | null;
|
||||
canLogin: boolean;
|
||||
localEnvironment?: boolean;
|
||||
onConnected: (connection: ProviderConnection) => void;
|
||||
onBack: () => void;
|
||||
testConnection: (connection: ProviderConnection) => Promise<boolean>;
|
||||
testError?: string | null;
|
||||
/** Connections supplies its access intent; presentation and login controllers stay shared. */
|
||||
managedAccount?: {
|
||||
intent: AiConnectionLoginIntent;
|
||||
initialMethod?: "subscription" | "api_key";
|
||||
fixedMethod?: boolean;
|
||||
disabled?: boolean;
|
||||
onComplete: (result: { connectionId: string; grantId: string; method: "subscription" | "api_key" }) => void;
|
||||
};
|
||||
}) {
|
||||
const health = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get, enabled: localEnvironment });
|
||||
const canUseLocalLogin = localEnvironment && health.data?.deploymentMode === "local_trusted";
|
||||
const epoch = useRef(0);
|
||||
useEffect(
|
||||
() => () => {
|
||||
|
|
@ -56,21 +75,32 @@ export function AgentProviderConnection({
|
|||
epoch.current++;
|
||||
setBusy(false);
|
||||
setOpened(false);
|
||||
setAuthorizationUrl(null);
|
||||
setLoginPhase("preparing");
|
||||
};
|
||||
const [methodChoice, setMethod] = useState<"subscription" | "api" | null>(null);
|
||||
const [methodChoice, setMethod] = useState<"subscription" | "api" | null>(managedAccount?.initialMethod === "api_key" ? "api" : managedAccount ? "subscription" : null);
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);
|
||||
const [loginPhase, setLoginPhase] = useState<"preparing" | "ready" | "waiting" | "connecting">("preparing");
|
||||
const phaseBeforeSubmit = useRef<"ready" | "waiting">("ready");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [storedConnection, setStoredConnection] =
|
||||
useState<ProviderConnection | null>(null);
|
||||
const provider = adapterType === "claude_local" ? "Claude" : "OpenAI";
|
||||
const provider = adapterType === "claude_local" ? "Claude" : adapterType === "grok_local" ? "Grok" : "OpenAI";
|
||||
const envKey =
|
||||
adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
||||
const savedKeys = useSavedProviderKeys(companyId, envKey);
|
||||
adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : adapterType === "grok_local" ? "XAI_API_KEY" : "OPENAI_API_KEY";
|
||||
const aiProvider = adapterType === "claude_local" ? "anthropic" : adapterType === "grok_local" ? "xai" : "openai";
|
||||
const availableKeys = useSavedProviderKeys(companyId, envKey);
|
||||
// Add/reconnect creates the requested account, never copies a saved account's
|
||||
// credential or silently changes its ownership. Agent setup retains reuse.
|
||||
const savedKeys = managedAccount
|
||||
? { ...availableKeys, options: [], subscriptions: [], loading: false }
|
||||
: availableKeys;
|
||||
const [subscriptionId, setSubscriptionId] = useState<string | null>(null);
|
||||
const savedSubscription =
|
||||
adapterType === "codex_local"
|
||||
savedKeys.subscriptions.length
|
||||
? savedKeys.subscriptions.find(
|
||||
(option) =>
|
||||
option.id === (subscriptionId ?? savedKeys.subscriptions[0]?.id),
|
||||
|
|
@ -80,11 +110,18 @@ export function AgentProviderConnection({
|
|||
const selectedKey = savedKeys.options.find(
|
||||
(option) => option.id === (selectedKeyId ?? savedKeys.options[0]?.id),
|
||||
);
|
||||
const storedLogin = savedKeys.storedLogin;
|
||||
const storedLogin = managedAccount
|
||||
? { ...savedKeys.storedLogin, data: undefined, isPending: false, isError: false }
|
||||
: savedKeys.storedLogin;
|
||||
const savedManagedAccount = useRef<{ connectionId: string; grantId: string } | null>(null);
|
||||
const method = methodChoice ?? (
|
||||
(adapterType === "claude_local" ? storedLogin.data : savedKeys.subscriptions.length)
|
||||
(savedKeys.subscriptions.length > 0 || (adapterType === "claude_local" && !savedSubscription && storedLogin.data))
|
||||
? "subscription" : savedKeys.options.length ? "api" : "subscription"
|
||||
);
|
||||
const localLogin = useLocalAiLogin(companyId, managedAccount?.intent ?? {
|
||||
provider: aiProvider, method: "subscription", name: `My ${provider} subscription`,
|
||||
ownership: "personal", agentIds: [], allAgents: true,
|
||||
}, canUseLocalLogin && method === "subscription" && !savedSubscription && !storedLogin.data);
|
||||
const auth = useQuery({
|
||||
queryKey: queryKeys.agents.authSignal(
|
||||
companyId,
|
||||
|
|
@ -98,32 +135,52 @@ export function AgentProviderConnection({
|
|||
environmentId ?? undefined,
|
||||
),
|
||||
retry: false,
|
||||
enabled: !managedAccount,
|
||||
});
|
||||
async function connect() {
|
||||
if (busy) return;
|
||||
if (busy || managedAccount?.disabled) return;
|
||||
const run = ++epoch.current;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const connection =
|
||||
if (managedAccount) {
|
||||
if (method === "subscription" && !canUseLocalLogin) return;
|
||||
const result = savedManagedAccount.current ?? await (method === "api"
|
||||
? aiConnectionsApi.create(companyId, { ...managedAccount.intent, method: "api_key", apiKey: apiKey.trim() })
|
||||
: localLogin.connect(managedAccount.intent));
|
||||
savedManagedAccount.current = result;
|
||||
setApiKey("");
|
||||
if (run === epoch.current) managedAccount.onComplete({ ...result, method: method === "api" ? "api_key" : "subscription" });
|
||||
return;
|
||||
}
|
||||
let connection: ProviderConnection =
|
||||
method === "api"
|
||||
? selectedKey
|
||||
? { env: { [envKey]: selectedKey.binding } }
|
||||
? selectedKey.aiConnection ? { env: {}, aiConnection: selectedKey.aiConnection } : { env: { [envKey]: selectedKey.binding } }
|
||||
: (storedConnection ?? {
|
||||
env: {},
|
||||
credentials: { [envKey]: apiKey.trim() },
|
||||
})
|
||||
: {
|
||||
env: savedSubscription
|
||||
...(savedSubscription?.aiConnection ? { aiConnection: savedSubscription.aiConnection } : {}),
|
||||
env: savedSubscription?.binding
|
||||
? { CODEX_HOME: savedSubscription.binding }
|
||||
: {},
|
||||
...(adapterType === "claude_local" && storedLogin.data
|
||||
...(adapterType === "claude_local" && !savedSubscription && storedLogin.data
|
||||
? {
|
||||
env: buildFixedClaudeOAuthBinding(),
|
||||
applyStoredClaudeLogin: true,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (method === "subscription" && canUseLocalLogin && !savedSubscription && !storedLogin.data) {
|
||||
savedManagedAccount.current ??= await localLogin.connect();
|
||||
connection = { env: {}, aiConnection: { provider: aiProvider, method: "subscription", mode: "responsible_user" } };
|
||||
}
|
||||
if (connection.credentials) {
|
||||
await aiConnectionsApi.create(companyId, { provider: aiProvider, method: "api_key", name: `My ${provider} API`, ownership: "personal", apiKey: connection.credentials[envKey], agentIds: [], allAgents: true });
|
||||
connection = { env: {}, aiConnection: { provider: aiProvider, method: "api_key", mode: "responsible_user" } };
|
||||
}
|
||||
if (run !== epoch.current) return;
|
||||
if (method === "api") {
|
||||
setApiKey("");
|
||||
|
|
@ -138,6 +195,7 @@ export function AgentProviderConnection({
|
|||
);
|
||||
} catch (cause) {
|
||||
if (run !== epoch.current) return;
|
||||
if (managedAccount) setApiKey("");
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
|
|
@ -154,9 +212,9 @@ export function AgentProviderConnection({
|
|||
!savedSubscription &&
|
||||
!savedKeys.loading &&
|
||||
!storedLogin.data &&
|
||||
(auth.data?.status !== "present" || subscriptionId === "");
|
||||
(Boolean(managedAccount) || auth.data?.status !== "present" || subscriptionId === "");
|
||||
return (
|
||||
<div>
|
||||
<div className="min-w-0 max-w-full">
|
||||
<ModelSourceTiles
|
||||
label="Connect your model provider"
|
||||
sources={[
|
||||
|
|
@ -165,7 +223,7 @@ export function AgentProviderConnection({
|
|||
label: provider,
|
||||
icon: (
|
||||
<img
|
||||
src={`/brands/${adapterType === "claude_local" ? "claude" : "codex"}-color.svg`}
|
||||
src={adapterType === "grok_local" ? "/brands/adapters/grok.svg" : `/brands/${adapterType === "claude_local" ? "claude" : "codex"}-color.svg`}
|
||||
className="size-6"
|
||||
alt=""
|
||||
/>
|
||||
|
|
@ -175,13 +233,14 @@ export function AgentProviderConnection({
|
|||
mode={method}
|
||||
selectedId={opened ? adapterType : null}
|
||||
collapsed={opened}
|
||||
onSelect={() => setOpened(true)}
|
||||
onSelect={() => { if (!managedAccount?.disabled) setOpened(true); }}
|
||||
/>
|
||||
{!opened && (
|
||||
{!opened && !managedAccount?.fixedMethod && (
|
||||
<div className="-ml-3 mt-1">
|
||||
<CredentialModeLink
|
||||
mode={method}
|
||||
onChange={(next) => {
|
||||
savedManagedAccount.current = null;
|
||||
setMethod(next);
|
||||
setError(null);
|
||||
}}
|
||||
|
|
@ -195,7 +254,6 @@ export function AgentProviderConnection({
|
|||
</p>
|
||||
)}
|
||||
{method === "subscription" &&
|
||||
adapterType === "codex_local" &&
|
||||
savedKeys.subscriptions.length > 0 && (
|
||||
<SavedProviderKeySelect
|
||||
options={savedKeys.subscriptions}
|
||||
|
|
@ -261,26 +319,48 @@ export function AgentProviderConnection({
|
|||
adapterType={adapterType}
|
||||
environmentId={environmentId}
|
||||
chrome="onboarding"
|
||||
aiConnection={managedAccount?.intent ?? { provider: aiProvider, method: "subscription", name: `My ${provider} subscription`, ownership: "personal", agentIds: [], allAgents: true }}
|
||||
autoStart
|
||||
onStored={(storedSessionId) => {
|
||||
const connection = {
|
||||
env: buildFixedClaudeOAuthBinding(),
|
||||
storedSessionId,
|
||||
};
|
||||
onStored={() => {}}
|
||||
onPromptReady={(url) => {
|
||||
setAuthorizationUrl(url);
|
||||
setLoginPhase((phase) => url ? (phase === "preparing" ? "ready" : phase) : "preparing");
|
||||
}}
|
||||
onCodeSubmitted={() => {
|
||||
phaseBeforeSubmit.current = loginPhase === "waiting" ? "waiting" : "ready";
|
||||
setLoginPhase("connecting");
|
||||
}}
|
||||
onSubmitFailed={() => {
|
||||
setLoginPhase((phase) => phase === "connecting" ? phaseBeforeSubmit.current : phase);
|
||||
}}
|
||||
onConnected={(sessionId) => {
|
||||
if (managedAccount) {
|
||||
if (!sessionId) { setError("The login did not return a saved connection. Try again."); return; }
|
||||
const run = epoch.current;
|
||||
setLoginPhase("connecting");
|
||||
void aiConnectionsApi.loginResult(companyId, sessionId).then((result) => {
|
||||
if (run === epoch.current) managedAccount.onComplete({ ...result, method: "subscription" });
|
||||
}).catch(() => {
|
||||
if (run !== epoch.current) return;
|
||||
setLoginPhase("ready");
|
||||
setError("Could not retrieve the saved connection. Go back and retry.");
|
||||
});
|
||||
return;
|
||||
}
|
||||
const connection: ProviderConnection = { env: {}, aiConnection: { provider: aiProvider, method: "subscription", mode: "responsible_user" } };
|
||||
setStoredConnection(connection);
|
||||
onConnected(connection);
|
||||
}}
|
||||
onConnected={() => {
|
||||
if (adapterType === "codex_local") onConnected({ env: {} });
|
||||
}}
|
||||
/>
|
||||
) : savedSubscription ? null : (
|
||||
) : savedSubscription ? null : localEnvironment && !storedLogin.data ? (
|
||||
<LocalProviderLoginInstructions adapterType={adapterType} login={{ ...localLogin, retry: () => { setError(null); localLogin.retry(); } }} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{storedLogin.data
|
||||
? "Use your saved Claude subscription for this agent."
|
||||
: canLogin
|
||||
? "Use the existing provider connection for this environment."
|
||||
: `Use the ${provider} login on this machine. If you haven’t signed in yet, run ${adapterType === "claude_local" ? "claude auth login" : "codex login"} in your terminal, then connect.`}
|
||||
: "This environment does not support browser sign-in. Choose a sign-in environment or connect with an API key."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -302,7 +382,11 @@ export function AgentProviderConnection({
|
|||
else onBack();
|
||||
}}
|
||||
primaryLabel={
|
||||
busy
|
||||
opened && needsLogin
|
||||
? loginPhase === "waiting" ? "Waiting for code"
|
||||
: loginPhase === "connecting" ? "Connecting"
|
||||
: `Sign in to ${provider}`
|
||||
: busy
|
||||
? "Connecting"
|
||||
: method === "subscription" &&
|
||||
(storedLogin.data || savedSubscription)
|
||||
|
|
@ -312,18 +396,28 @@ export function AgentProviderConnection({
|
|||
: "Connect"
|
||||
}
|
||||
primaryDisabled={
|
||||
auth.isPending ||
|
||||
managedAccount?.disabled ||
|
||||
(Boolean(managedAccount) && method === "subscription" && !canLogin && !canUseLocalLogin) ||
|
||||
(localEnvironment && health.isPending) || localLogin.preparing || Boolean(localLogin.error) ||
|
||||
(!managedAccount && auth.isPending) ||
|
||||
savedKeys.loading ||
|
||||
(adapterType === "claude_local" && storedLogin.isPending) ||
|
||||
!opened ||
|
||||
Boolean(needsLogin) ||
|
||||
(Boolean(needsLogin) && (!authorizationUrl || loginPhase !== "ready")) ||
|
||||
(method === "api" &&
|
||||
!apiKey.trim() &&
|
||||
!storedConnection &&
|
||||
!selectedKey)
|
||||
}
|
||||
loading={busy}
|
||||
onPrimary={() => void connect()}
|
||||
primaryIcon={opened && needsLogin ? loginPhase === "ready" ? "none" : "spinner" : undefined}
|
||||
onPrimary={() => {
|
||||
if (needsLogin) {
|
||||
if (!authorizationUrl || loginPhase !== "ready") return;
|
||||
window.open(authorizationUrl, "_blank", "noreferrer,noopener");
|
||||
setLoginPhase("waiting");
|
||||
} else void connect();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { AiConnectionField, aiProviderForAdapter } from "../ai-connections/AiConnectionField";
|
||||
import type { AiConnectionBinding } from "@paperclipai/shared";
|
||||
import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
|
||||
import {
|
||||
SETUP_CREDENTIAL_KEYS,
|
||||
|
|
@ -114,7 +116,7 @@ function Setup({
|
|||
: "codex_local"
|
||||
: adapterType;
|
||||
const connectionAdapter =
|
||||
brandType === "claude_local" || brandType === "codex_local"
|
||||
brandType === "claude_local" || brandType === "codex_local" || brandType === "grok_local"
|
||||
? brandType
|
||||
: null;
|
||||
const multiProvider =
|
||||
|
|
@ -141,7 +143,13 @@ function Setup({
|
|||
const [providerBinding, setProviderBinding] = useState<EnvBinding | null>(
|
||||
null,
|
||||
);
|
||||
const [runtimeAiBinding, setRuntimeAiBinding] = useState<AiConnectionBinding | undefined>(() =>
|
||||
brandType === "opencode_local"
|
||||
? { provider: "openrouter", method: "api_key", mode: "responsible_user" }
|
||||
: undefined,
|
||||
);
|
||||
const [connection, setConnection] = useState<ProviderConnection | null>(null);
|
||||
const aiBinding = runtimeAiBinding ?? connection?.aiConnection;
|
||||
const [repository, setRepository] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [createdInSession, setCreated] = useState<Agent | null>(null);
|
||||
|
|
@ -203,8 +211,8 @@ function Setup({
|
|||
queryFn: () => environmentsApi.capabilities(companyId),
|
||||
});
|
||||
const models = useQuery({
|
||||
queryKey: queryKeys.agents.adapterModels(companyId, brandType),
|
||||
queryFn: () => agentsApi.adapterModels(companyId, brandType),
|
||||
queryKey: queryKeys.agents.adapterModels(companyId, brandType, null, aiBinding?.provider),
|
||||
queryFn: () => agentsApi.adapterModels(companyId, brandType, { provider: aiBinding?.provider }),
|
||||
enabled: Boolean(brandType) && showModel,
|
||||
retry: false,
|
||||
});
|
||||
|
|
@ -343,7 +351,7 @@ function Setup({
|
|||
...(runnerProvider === "claude" ? { acpxAgent: "claude" } : {}),
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
if (hasCredentialField && binding) {
|
||||
if (!aiBinding && !nextConnection?.aiConnection && hasCredentialField && binding) {
|
||||
if (adapterType === "hermes_gateway") config.apiKey = binding;
|
||||
else
|
||||
config.env = { ...((config.env as object) ?? {}), [envKey]: binding };
|
||||
|
|
@ -401,6 +409,7 @@ function Setup({
|
|||
return buildConfig(nextConnection);
|
||||
}
|
||||
function pendingCredentials(nextConnection = connection) {
|
||||
if (aiBinding || nextConnection?.aiConnection) return {};
|
||||
return {
|
||||
...nextConnection?.credentials,
|
||||
...(hasCredentialField && apiKey.trim()
|
||||
|
|
@ -423,6 +432,7 @@ function Setup({
|
|||
providerAdapter: brandType,
|
||||
adapterConfig: config,
|
||||
testCredentials: pendingCredentials(nextConnection),
|
||||
aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection,
|
||||
environmentId,
|
||||
});
|
||||
if (run !== generation.current) return false;
|
||||
|
|
@ -494,7 +504,7 @@ function Setup({
|
|||
defaultEnvironmentId:
|
||||
environmentOverride ||
|
||||
(forced.forced || managedOnly ? environmentId : null),
|
||||
runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
|
||||
runtimeConfig: { ...buildNewAgentRuntimeConfig({ heartbeatEnabled: false }), ...(aiBinding ? { aiConnection: aiBinding } : {}) },
|
||||
budgetMonthlyCents: 0,
|
||||
...(connection?.storedSessionId
|
||||
? { storedSessionId: connection.storedSessionId }
|
||||
|
|
@ -700,7 +710,7 @@ function Setup({
|
|||
<div className="mb-8">
|
||||
<OnboardingHeading
|
||||
title="Connect a model"
|
||||
lede={`Connect ${name} to ${connectionAdapter === "claude_local" ? "Claude" : "OpenAI"}.`}
|
||||
lede={`Connect ${name} to ${connectionAdapter === "claude_local" ? "Claude" : connectionAdapter === "grok_local" ? "Grok" : "OpenAI"}.`}
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -710,6 +720,7 @@ function Setup({
|
|||
adapterType={connectionAdapter}
|
||||
environmentId={environmentId}
|
||||
canLogin={canLogin}
|
||||
localEnvironment={environment?.driver === "local"}
|
||||
onBack={() => navigate("/agents/all")}
|
||||
testConnection={runTest}
|
||||
testError={
|
||||
|
|
@ -758,7 +769,7 @@ function Setup({
|
|||
<p className="text-sm text-muted-foreground">
|
||||
{created.status === "pending_approval"
|
||||
? "An organization administrator must approve this agent before it can work."
|
||||
: "Your agent has not started running."}
|
||||
: "Assign a task when you’re ready for this agent to work."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
|
|
@ -797,6 +808,9 @@ function Setup({
|
|||
<fieldset disabled={busy} className="space-y-8">
|
||||
<section className="space-y-5">
|
||||
<h3 className="text-sm font-semibold">Runtime</h3>
|
||||
{aiProviderForAdapter(brandType) && <AiConnectionField companyId={companyId} agentName={name} adapterType={brandType} model={model} environmentId={environmentId ?? undefined} value={aiBinding}
|
||||
onChange={binding => { setRuntimeAiBinding(binding); resetTest(); }} />}
|
||||
{models.error && <p role="alert" className="text-sm text-destructive">Could not load models. Retry or enter a model ID manually.</p>}
|
||||
{((showModel && !usingKimiApi) ||
|
||||
efforts.length > 0) && (
|
||||
<div className="grid items-start gap-5 sm:grid-cols-2">
|
||||
|
|
@ -866,7 +880,7 @@ function Setup({
|
|||
manually.
|
||||
</p>
|
||||
)}
|
||||
{hasCredentialField && (
|
||||
{hasCredentialField && !aiBinding && (
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
{chooseProvider && (
|
||||
<Field label="API key provider">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import type { AiProvider } from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { ApiError } from "@/api/client";
|
||||
|
|
@ -5,6 +7,7 @@ import { secretsApi } from "@/api/secrets";
|
|||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
savedProviderKeys,
|
||||
savedManagedProviderAccounts,
|
||||
savedCodexSubscriptions,
|
||||
type SavedProviderKey,
|
||||
} from "@/lib/saved-provider-credentials";
|
||||
|
|
@ -14,6 +17,14 @@ export function useSavedProviderKeys(
|
|||
envKey: string,
|
||||
enabled = true,
|
||||
) {
|
||||
const provider = ({ ANTHROPIC_API_KEY: "anthropic", OPENAI_API_KEY: "openai", OPENROUTER_API_KEY: "openrouter", XAI_API_KEY: "xai" } as Record<string, AiProvider>)[envKey];
|
||||
const managed = useQuery({
|
||||
queryKey: ["ai-connections", companyId],
|
||||
queryFn: () => aiConnectionsApi.list(companyId!),
|
||||
enabled: Boolean(companyId && provider) && enabled,
|
||||
retry: false,
|
||||
});
|
||||
const managedAccounts = provider && managed.data ? savedManagedProviderAccounts(companyId!, provider, managed.data.currentUserId, managed.data.connections) : [];
|
||||
const personal = useQuery({
|
||||
queryKey: queryKeys.secrets.myUserSecrets(companyId ?? ""),
|
||||
queryFn: () => secretsApi.listMyUserSecrets(companyId!),
|
||||
|
|
@ -43,19 +54,19 @@ export function useSavedProviderKeys(
|
|||
});
|
||||
return {
|
||||
storedLogin,
|
||||
options: savedProviderKeys(
|
||||
options: [...managedAccounts.filter(account => account.aiConnection?.method === "api_key"), ...savedProviderKeys(
|
||||
companyId ?? "",
|
||||
envKey,
|
||||
personal.data ?? [],
|
||||
organization.data ?? [],
|
||||
),
|
||||
subscriptions: savedCodexSubscriptions(
|
||||
)],
|
||||
subscriptions: [...managedAccounts.filter(account => account.aiConnection?.method === "subscription"), ...(provider === "openai" ? savedCodexSubscriptions(
|
||||
companyId ?? "",
|
||||
organization.data ?? [],
|
||||
),
|
||||
) : [])],
|
||||
// Background refreshes must not unmount an active login panel sharing this query.
|
||||
loading: personal.isLoading || organization.isLoading || storedLogin.isLoading,
|
||||
error: personal.isError || organization.isError,
|
||||
loading: personal.isLoading || organization.isLoading || storedLogin.isLoading || managed.isLoading,
|
||||
error: personal.isError || organization.isError || managed.isError,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,18 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
|||
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.comments("chat-1") });
|
||||
client.clear();
|
||||
});
|
||||
it.each(["ai_connection.default_changed", "ai_connection.reconnected", "connection_grant.revoked"])(
|
||||
"refreshes company AI account previews after %s", (action) => {
|
||||
const invalidateQueries = vi.fn();
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
{ invalidateQueries, getQueryData: () => undefined } as never,
|
||||
"company-1", { entityType: "connection_grant", entityId: "grant-1", action },
|
||||
{ userId: "owner", agentId: null },
|
||||
);
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["ai-connections", "company-1"] });
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ["ai-connections"] });
|
||||
},
|
||||
);
|
||||
|
||||
it("refreshes touched inbox queries and only the changed issue data for issue updates", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
|
|
|
|||
|
|
@ -1264,6 +1264,10 @@ function invalidateActivityQueries(
|
|||
!!currentActor.agentId &&
|
||||
actorId === currentActor.agentId);
|
||||
|
||||
if (action?.startsWith("ai_connection.") || action?.startsWith("connection_grant.")) {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-connections", companyId] });
|
||||
}
|
||||
|
||||
if (action?.startsWith("resource_membership.")) {
|
||||
const targetUserId = readString(details?.userId);
|
||||
if (!targetUserId || targetUserId === currentActor.userId) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Check, ChevronRight, Loader2 } from "lucide-react";
|
||||
|
||||
/** The account-reuse rows shared by connection setup and agent bindings. */
|
||||
export function ConnectionChoiceList({ choices, selectedId, pendingId, disabled, onSelect }: {
|
||||
choices: { id: string; name: string; description: ReactNode; disabled?: boolean }[];
|
||||
selectedId?: string;
|
||||
pendingId?: string | null;
|
||||
disabled?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return <div className="space-y-2">
|
||||
{choices.map((choice) => <button
|
||||
key={choice.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label={choice.name}
|
||||
aria-pressed={selectedId === undefined ? undefined : selectedId === choice.id}
|
||||
disabled={disabled || Boolean(pendingId) || choice.disabled}
|
||||
onClick={() => onSelect(choice.id)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-foreground">{choice.name}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{choice.description}</span>
|
||||
</span>
|
||||
{pendingId === choice.id ? <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
: selectedId === choice.id ? <Check className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
</button>)}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import { ConnectionIntentInteractionBody } from "./ConnectionIntentInteractionBody";
|
||||
|
||||
const credentialRender = vi.hoisted(() => vi.fn());
|
||||
const setupOptionsMock = vi.hoisted(() => vi.fn());
|
||||
const completeMock = vi.hoisted(() => vi.fn());
|
||||
const declineMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -30,6 +31,14 @@ vi.mock("@/api/connection-intents", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ai-connections/AiConnectionCredentialStep", () => ({
|
||||
AiConnectionCredentialStep: (props: { connectionId?: string; name: string; fixedMethod?: boolean; onComplete: (result: {connectionId: string; grantId: string; method: "api_key"}) => void; onCancel: () => void }) => { credentialRender(props); return <div data-testid="shared-ai-credentials">
|
||||
<span>{props.name}</span><span>{String(props.fixedMethod)}</span>
|
||||
<button onClick={() => props.onComplete({ connectionId: props.connectionId!, grantId: "grant", method: "api_key" })}>Reconnect selected account</button>
|
||||
<button onClick={props.onCancel}>Cancel repair</button>
|
||||
</div>; },
|
||||
}));
|
||||
|
||||
vi.mock("./ConnectionSetupFlow", () => ({
|
||||
ConnectionSetupFlow: (props: {
|
||||
requestedAgentId?: string;
|
||||
|
|
@ -402,3 +411,54 @@ describe("ConnectionIntentInteractionBody dialog behavior", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("AI repair inside the card", () => {
|
||||
const interaction: ConnectionIntentInteraction = { ...pendingConnectionIntentInteraction, payload: { ...pendingConnectionIntentInteraction.payload, purpose: "ai" } };
|
||||
const connection = { id: "selected-account", name: "My Codex account", provider: "openai", method: "api_key", ownership: "personal", ownerName: "Dotta", status: "revoked" };
|
||||
it("reuses authentication inline, preserves the selected account, cancels with focus, and completes", async () => {
|
||||
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection, canReconnect: true } });
|
||||
completeMock.mockResolvedValue({ ...interaction, status: "accepted" });
|
||||
renderBody(interaction);
|
||||
await flush();
|
||||
await act(() => button("Fix connection")!.click());
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(document.querySelector('[data-testid="ai-connection-inline-repair"]')?.textContent).toContain("My Codex account");
|
||||
await act(() => button("Cancel repair")!.click());
|
||||
await waitForAssertion(() => expect(document.activeElement?.getAttribute("data-testid")).toBe("connection-intent-focus-target"));
|
||||
expect(completeMock).not.toHaveBeenCalled();
|
||||
await act(() => button("Fix connection")!.click());
|
||||
await act(() => button("Reconnect selected account")!.click());
|
||||
expect(completeMock).toHaveBeenCalledWith(interaction.id, "selected-account");
|
||||
});
|
||||
it("keeps a late credential save after cancellation from accepting the request", async () => {
|
||||
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection, canReconnect: true } });
|
||||
renderBody(interaction); await flush();
|
||||
await act(() => button("Fix connection")!.click());
|
||||
const abandoned = credentialRender.mock.lastCall![0];
|
||||
await act(() => button("Cancel repair")!.click());
|
||||
await act(() => button("Fix connection")!.click());
|
||||
await act(() => abandoned.onComplete({ connectionId: connection.id, grantId: "grant", method: "api_key" }));
|
||||
expect(completeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("offers continuation for the restored account without another login", async () => {
|
||||
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [connection], aiRepair: { connection, canReconnect: true } });
|
||||
renderBody(interaction); await flush();
|
||||
await act(() => button("Fix connection")!.click());
|
||||
expect(document.querySelector('[data-testid="shared-ai-credentials"]')).toBeNull();
|
||||
await act(() => button("Continue task")!.click());
|
||||
expect(completeMock).toHaveBeenCalledWith(interaction.id, connection.id);
|
||||
});
|
||||
it("does not let another user reconnect the owner's account", async () => {
|
||||
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection, canReconnect: false } });
|
||||
renderBody(interaction); await flush();
|
||||
await act(() => button("Fix connection")!.click());
|
||||
expect(document.body.textContent).toContain("Dotta must reconnect My Codex account");
|
||||
expect(document.querySelector('[data-testid="shared-ai-credentials"]')).toBeNull();
|
||||
});
|
||||
it("does not promise to run without credentials when declined", () => {
|
||||
renderBody({ ...interaction, status: "rejected" });
|
||||
expect(document.body.textContent).toContain("The task still needs a working AI connection");
|
||||
expect(document.body.textContent).not.toContain("can continue without it");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import type { ConnectionIntentInteraction } from "@paperclipai/shared";
|
||||
import { connectionIntentsApi } from "@/api/connection-intents";
|
||||
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -23,26 +24,36 @@ import {
|
|||
import {
|
||||
ConnectionSetupFlow,
|
||||
type ConnectionSetupCompletion,
|
||||
type ConnectionSetupFlowProps,
|
||||
} from "./ConnectionSetupFlow";
|
||||
|
||||
export interface ConnectionIntentInteractionBodyProps {
|
||||
interaction: ConnectionIntentInteraction;
|
||||
currentUserId?: string | null;
|
||||
addresseeLabel: string;
|
||||
renderSetup?: (props: ConnectionSetupFlowProps) => ReactNode;
|
||||
}
|
||||
|
||||
export function ConnectionIntentInteractionBody({
|
||||
interaction,
|
||||
currentUserId,
|
||||
addresseeLabel,
|
||||
renderSetup,
|
||||
}: ConnectionIntentInteractionBodyProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const focusTargetRef = useRef<HTMLDivElement>(null);
|
||||
const setupGeneration = useRef(0);
|
||||
const generation = setupGeneration.current;
|
||||
const closeSetup = () => {
|
||||
setupGeneration.current += 1;
|
||||
setOpen(false);
|
||||
};
|
||||
const queryClient = useQueryClient();
|
||||
const isAddressee = Boolean(
|
||||
currentUserId && interaction.addresseeUserId === currentUserId,
|
||||
);
|
||||
const isPending = interaction.status === "pending";
|
||||
const isAi = interaction.payload.purpose === "ai";
|
||||
const focusTargetId = `connection-intent-focus-target-${interaction.id}`;
|
||||
|
||||
const invalidateTask = async (
|
||||
|
|
@ -131,6 +142,12 @@ export function ConnectionIntentInteractionBody({
|
|||
);
|
||||
|
||||
const finishNewConnection = async (completion: ConnectionSetupCompletion) => {
|
||||
// A completed credential save survives cancellation, but an abandoned form
|
||||
// must not accept the task request (even if a new form has since opened).
|
||||
if (isAi && generation !== setupGeneration.current) {
|
||||
await setupQuery.refetch();
|
||||
return;
|
||||
}
|
||||
if (completion.resolvedByCallback) {
|
||||
// A browser message cannot establish authorization. Read the durable result.
|
||||
const verified = await setupQuery.refetch();
|
||||
|
|
@ -143,19 +160,34 @@ export function ConnectionIntentInteractionBody({
|
|||
completeMutation.mutate(completion.connectionId);
|
||||
};
|
||||
|
||||
const setupProps: ConnectionSetupFlowProps | null = setupQuery.data ? {
|
||||
host: "dialog",
|
||||
serviceSlug: interaction.payload.serviceSlug.startsWith("connection:") ? undefined : interaction.payload.serviceSlug,
|
||||
configuredConnection: interaction.payload.serviceSlug.startsWith("connection:") ? setupQuery.data.existingConnections[0] : undefined,
|
||||
requestedAgentId: setupQuery.data.requestedAgentId,
|
||||
aiConnection: setupQuery.data.aiConnection,
|
||||
interactionId: interaction.id,
|
||||
existingConnections: setupQuery.data.existingConnections,
|
||||
onUseExisting: async (connectionId) => { await completeMutation.mutateAsync(connectionId); },
|
||||
onComplete: (completion) => { void finishNewConnection(completion); },
|
||||
onOAuthDeclined: () => declineMutation.mutate(),
|
||||
onPhaseChange: handlePhaseChange,
|
||||
onCancel: () => { closeSetup(); returnFocusToCard(); },
|
||||
} : null;
|
||||
|
||||
const resultOutcome = interaction.result?.outcome;
|
||||
const status =
|
||||
interaction.status === "accepted"
|
||||
? {
|
||||
icon: CheckCircle2,
|
||||
title: `${interaction.payload.serviceName} connected`,
|
||||
body: `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`,
|
||||
body: isAi ? "The connection was restored for this request." : `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`,
|
||||
}
|
||||
: interaction.status === "rejected"
|
||||
? {
|
||||
icon: XCircle,
|
||||
title: "Connection declined",
|
||||
body: `${interaction.payload.requestingAgentName} was notified and can continue without it.`,
|
||||
body: isAi ? "The task still needs a working AI connection before it can run." : `${interaction.payload.requestingAgentName} was notified and can continue without it.`,
|
||||
}
|
||||
: interaction.status === "expired"
|
||||
? {
|
||||
|
|
@ -224,6 +256,61 @@ export function ConnectionIntentInteractionBody({
|
|||
const needsRetry = interaction.payload.phase === "needs_retry";
|
||||
const authorizing = interaction.payload.phase === "authorizing";
|
||||
|
||||
const repair = setupQuery.data?.aiRepair;
|
||||
const selectedReady = repair && setupQuery.data?.existingConnections.some((connection) => connection.id === repair.connection.id);
|
||||
const setupContent = setupQuery.isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading
|
||||
connection options…
|
||||
</div>
|
||||
) : setupQuery.isError ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="font-medium text-foreground">
|
||||
Couldn’t load connection setup
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{setupQuery.error instanceof Error
|
||||
? setupQuery.error.message
|
||||
: "Try again."}
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
onClick={() => setupQuery.refetch()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : setupProps ? (
|
||||
renderSetup ? renderSetup(setupProps) : <ConnectionSetupFlow {...setupProps} />
|
||||
) : null;
|
||||
const inlineContent = setupQuery.isLoading || setupQuery.isError ? setupContent
|
||||
: selectedReady ? <div className="space-y-3">
|
||||
<p className="text-sm">{repair.connection.name} is ready.</p>
|
||||
<Button disabled={completeMutation.isPending} onClick={() => completeMutation.mutate(repair.connection.id)}>
|
||||
{completeMutation.isPending ? "Continuing…" : "Continue task"}
|
||||
</Button>
|
||||
</div>
|
||||
: repair ? repair.canReconnect ? <AiConnectionCredentialStep
|
||||
companyId={interaction.companyId}
|
||||
provider={repair.connection.provider}
|
||||
initialMethod={repair.connection.method}
|
||||
fixedMethod
|
||||
connectionId={repair.connection.id}
|
||||
name={repair.connection.name}
|
||||
ownership={repair.connection.ownership}
|
||||
agentIds={[interaction.payload.requestingAgentId]}
|
||||
allAgents={false}
|
||||
onComplete={(result) => { void finishNewConnection(result); }}
|
||||
onCancel={() => { closeSetup(); returnFocusToCard(); }}
|
||||
/> : <p role="status" className="text-sm text-muted-foreground">
|
||||
{repair.connection.ownership === "personal" ? `${repair.connection.ownerName ?? "The account owner"} must reconnect ${repair.connection.name}.` : `The account owner must reconnect ${repair.connection.name}.`}
|
||||
{" "}You can continue here once it is restored.
|
||||
</p>
|
||||
: setupQuery.data?.aiConnection && setupQuery.data.aiConnection.mode !== "responsible_user"
|
||||
? <p role="status" className="text-sm text-muted-foreground">The selected account is no longer available to you. Ask its owner to restore access, or choose an available AI connection in the agent’s settings.</p>
|
||||
: setupContent;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={focusTargetId}
|
||||
|
|
@ -241,12 +328,12 @@ export function ConnectionIntentInteractionBody({
|
|||
/>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{interaction.payload.requestingAgentName} needs{" "}
|
||||
{interaction.payload.serviceName}
|
||||
{isAi ? "AI connection needs attention" : `${interaction.payload.requestingAgentName} needs ${interaction.payload.serviceName}`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Connect your identity or reuse an eligible connection. Access is
|
||||
added only for this agent.
|
||||
{interaction.payload.purpose === "ai"
|
||||
? "Restore the agent’s selected AI account, then continue this task."
|
||||
: "Connect your identity or reuse an eligible connection. Access is added only for this agent."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -260,15 +347,17 @@ export function ConnectionIntentInteractionBody({
|
|||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
{!isAi && <Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={declineMutation.isPending || authorizing}
|
||||
disabled={declineMutation.isPending || completeMutation.isPending || authorizing}
|
||||
onClick={() => declineMutation.mutate()}
|
||||
>
|
||||
Not now
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
</Button>}
|
||||
{isAi ? <Button type="button" disabled={completeMutation.isPending} onClick={() => open ? closeSetup() : setOpen(true)}>
|
||||
<Plug className="h-4 w-4" />{open ? "Close setup" : "Fix connection"}
|
||||
</Button> : <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button">
|
||||
{authorizing ? (
|
||||
|
|
@ -298,51 +387,11 @@ export function ConnectionIntentInteractionBody({
|
|||
Complete connection setup without leaving this task.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{setupQuery.isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading
|
||||
connection options…
|
||||
</div>
|
||||
) : setupQuery.isError ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="font-medium text-foreground">
|
||||
Couldn’t load connection setup
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{setupQuery.error instanceof Error
|
||||
? setupQuery.error.message
|
||||
: "Try again."}
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
onClick={() => setupQuery.refetch()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : setupQuery.data ? (
|
||||
<ConnectionSetupFlow
|
||||
host="dialog"
|
||||
serviceSlug={interaction.payload.serviceSlug.startsWith("connection:") ? undefined : interaction.payload.serviceSlug}
|
||||
configuredConnection={interaction.payload.serviceSlug.startsWith("connection:") ? setupQuery.data.existingConnections[0] : undefined}
|
||||
requestedAgentId={setupQuery.data.requestedAgentId}
|
||||
interactionId={interaction.id}
|
||||
existingConnections={setupQuery.data.existingConnections}
|
||||
onUseExisting={async (connectionId) => {
|
||||
await completeMutation.mutateAsync(connectionId);
|
||||
}}
|
||||
onComplete={(completion) => {
|
||||
void finishNewConnection(completion);
|
||||
}}
|
||||
onOAuthDeclined={() => declineMutation.mutate()}
|
||||
onPhaseChange={handlePhaseChange}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{setupContent}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>}
|
||||
</div>
|
||||
{isAi && open ? <div className="mt-4 border-t border-border pt-4" data-testid="ai-connection-inline-repair">{inlineContent}</div> : null}
|
||||
|
||||
{completeMutation.isError ||
|
||||
declineMutation.isError ||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
|
||||
import { ConnectionChoiceList } from "./ConnectionChoiceList";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
|
|
@ -32,6 +34,7 @@ import type {
|
|||
ToolOAuthStartResult,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
aiConnectionMetadataSchema,
|
||||
connectionMethodAcceptsCustomerOAuthClient,
|
||||
connectionMethodRequiresConfiguration,
|
||||
connectionMethodSupportsAutomaticOAuth,
|
||||
|
|
@ -276,6 +279,7 @@ function appConnectHref(
|
|||
resumeConnectionId?: string | null;
|
||||
reconnectConnectionId?: string | null;
|
||||
interactionId?: string | null;
|
||||
connectionMethodKey?: string | null;
|
||||
},
|
||||
): string {
|
||||
const stage = ROUTE_STAGE_BY_STEP[step] ?? "setup";
|
||||
|
|
@ -283,6 +287,7 @@ function appConnectHref(
|
|||
if (existing?.resumeConnectionId) params.set("resume", existing.resumeConnectionId);
|
||||
if (existing?.reconnectConnectionId) params.set("reconnect", existing.reconnectConnectionId);
|
||||
if (existing?.interactionId) params.set("intent", existing.interactionId);
|
||||
if (existing?.connectionMethodKey) params.set("method", existing.connectionMethodKey);
|
||||
const path = credentialSource === "vercel_connect" ? "/apps/vercel-connect" : "/apps/connect";
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
|
@ -364,7 +369,7 @@ function availableToolConnectionMethods(
|
|||
entry: AppDefinition,
|
||||
): ConnectionMethodDef[] {
|
||||
return getAvailableConnectionMethods(entry).filter(
|
||||
(method) => (method.purpose ?? "tool") === "tool",
|
||||
(method) => (method.purpose ?? "tool") !== "channel",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -495,6 +500,9 @@ export function readConnectionIntentOAuthOutcome(
|
|||
}
|
||||
|
||||
export interface ConnectionSetupFlowProps {
|
||||
aiConnection?: import("@paperclipai/shared").AiConnectionBinding;
|
||||
/** Provider-specific authentication inside the existing access/setup shell. Undefined retains the standard credential form. */
|
||||
renderCredentialStep?: (context: { app: AppDefinition; name: string; grantKind: ConnectionGrantKind; agentIds: string[]; allAgents: boolean; onBack: () => void }) => ReactNode;
|
||||
byoOnly?: boolean;
|
||||
credentialSource?: ToolConnectionCredentialSource;
|
||||
host?: "page" | "dialog";
|
||||
|
|
@ -530,8 +538,10 @@ export function ConnectionSetupFlow({
|
|||
onUseExisting,
|
||||
onComplete,
|
||||
onOAuthDeclined,
|
||||
aiConnection,
|
||||
onPhaseChange,
|
||||
onCancel,
|
||||
renderCredentialStep,
|
||||
}: ConnectionSetupFlowProps = {}) {
|
||||
const routeNavigate = useNavigate();
|
||||
const navigate = useCallback((to: string, options?: { replace?: boolean }) => {
|
||||
|
|
@ -552,6 +562,7 @@ export function ConnectionSetupFlow({
|
|||
const sourceSlug = searchParams.get("source")?.trim() || null;
|
||||
const createNewConnection = forceNewConnection || searchParams.get("new") === "1";
|
||||
const routeStage = searchParams.get("stage")?.trim() || null;
|
||||
const requestedMethodKey = searchParams.get("method")?.trim() || null;
|
||||
const resumeConnectionId = searchParams.get("resume")?.trim() || null;
|
||||
const oauthCallbackOutcome = searchParams.get("oauth");
|
||||
const oauthCallbackCode = searchParams.get("code");
|
||||
|
|
@ -621,7 +632,7 @@ export function ConnectionSetupFlow({
|
|||
const [curatedOAuthClientId, setCuratedOAuthClientId] = useState("");
|
||||
const [curatedOAuthClientSecret, setCuratedOAuthClientSecret] = useState("");
|
||||
const [vercelConnector, setVercelConnector] = useState("");
|
||||
const [connectionMethodKey, setConnectionMethodKey] = useState("");
|
||||
const [connectionMethodKey, setConnectionMethodKey] = useState(aiConnection ? `ai-${aiConnection.method}` : "");
|
||||
const [configValues, setConfigValues] = useState<Record<string, string | boolean>>({});
|
||||
const [googleSheetsLinks, setGoogleSheetsLinks] = useState("");
|
||||
const [googleSheetsError, setGoogleSheetsError] = useState<string | null>(null);
|
||||
|
|
@ -906,6 +917,10 @@ export function ConnectionSetupFlow({
|
|||
const galleryQuery = useQuery({
|
||||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
|
||||
select: useCallback((data: Awaited<ReturnType<typeof toolsApi.listGallery>>) => connectionIntentId ? {
|
||||
...data,
|
||||
apps: data.apps.map(app => ({ ...app, methods: app.methods.filter(method => aiConnection ? method.ai?.provider === aiConnection.provider && method.ai.method === aiConnection.method : method.transport !== "runtime_auth") })).filter(app => app.methods.length > 0),
|
||||
} : data, [connectionIntentId, aiConnection?.provider, aiConnection?.method]),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
// Use the same visible catalog for cards and every branded URL shortcut.
|
||||
|
|
@ -1136,6 +1151,7 @@ export function ConnectionSetupFlow({
|
|||
setStep(nextStep);
|
||||
if (entry) {
|
||||
navigate(appConnectHref(entry.slug, nextStep, credentialSource, {
|
||||
connectionMethodKey: entry.methods.find(method => method.key === connectionMethodKey)?.ai ? connectionMethodKey : undefined,
|
||||
resumeConnectionId,
|
||||
reconnectConnectionId,
|
||||
interactionId: connectionIntentId,
|
||||
|
|
@ -1402,7 +1418,13 @@ export function ConnectionSetupFlow({
|
|||
&& connectorEnrollmentQuery.isLoading
|
||||
) return;
|
||||
const methods = connectionMethodsForCredentialSource(requestedEntry, credentialSource);
|
||||
const initialMethod = (
|
||||
const requestedAi = aiConnection ?? (reconnectConnection?.connectionPurpose === "ai"
|
||||
? aiConnectionMetadataSchema.safeParse(reconnectConnection.config?.ai).data
|
||||
: undefined);
|
||||
const explicitMethod = methods.find(candidate => requestedAi
|
||||
? candidate.ai?.provider === requestedAi.provider && candidate.ai.method === requestedAi.method
|
||||
: candidate.key === requestedMethodKey);
|
||||
const initialMethod = explicitMethod ?? (
|
||||
requestedDefinitionUsesManagedConnector
|
||||
&& !requestedEntryAdvertisesManagedConnector
|
||||
? recommendedManagedConnectorMethod(fullRequestedDefinition)
|
||||
|
|
@ -1502,6 +1524,8 @@ export function ConnectionSetupFlow({
|
|||
return;
|
||||
}
|
||||
}, [
|
||||
aiConnection,
|
||||
requestedMethodKey,
|
||||
applicationsQuery.isError,
|
||||
applicationsQuery.isFetchedAfterMount,
|
||||
applicationsQuery.data,
|
||||
|
|
@ -1529,6 +1553,10 @@ export function ConnectionSetupFlow({
|
|||
zapierSource,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reconnectConnection?.connectionPurpose === "ai" && step === "access") setStep("key");
|
||||
}, [reconnectConnection?.connectionPurpose, step]);
|
||||
|
||||
// Resume the exact method and non-secret provider configuration that the
|
||||
// interrupted draft already chose. Secrets are intentionally never read back
|
||||
// into the browser; credential-based methods ask for a replacement value.
|
||||
|
|
@ -1806,38 +1834,22 @@ export function ConnectionSetupFlow({
|
|||
Reuse a connection without changing who already has access, or connect a new one.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{existingConnections.map((connection) => (
|
||||
<button
|
||||
key={connection.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={existingConnectionPendingId !== null}
|
||||
onClick={async () => {
|
||||
setExistingConnectionPendingId(connection.id);
|
||||
setExistingConnectionError(null);
|
||||
try {
|
||||
await onUseExisting(connection.id);
|
||||
} catch (error) {
|
||||
setExistingConnectionError(error instanceof Error ? error.message : "Couldn’t use this connection.");
|
||||
setExistingConnectionPendingId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="block font-medium text-foreground">{connection.name}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{connection.status === "active" && connection.enabled ? "Ready to use" : "Setup needs attention"}
|
||||
</span>
|
||||
</span>
|
||||
{existingConnectionPendingId === connection.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ConnectionChoiceList
|
||||
choices={existingConnections.map((connection) => ({
|
||||
id: connection.id, name: connection.name,
|
||||
description: connection.status === "active" && connection.enabled ? "Ready to use" : "Setup needs attention",
|
||||
}))}
|
||||
pendingId={existingConnectionPendingId}
|
||||
onSelect={async (id) => {
|
||||
setExistingConnectionPendingId(id);
|
||||
setExistingConnectionError(null);
|
||||
try { await onUseExisting(id); }
|
||||
catch (error) {
|
||||
setExistingConnectionError(error instanceof Error ? error.message : "Couldn’t use this connection.");
|
||||
setExistingConnectionPendingId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{existingConnectionError ? (
|
||||
<InlineBanner tone="danger" className="mt-4">{existingConnectionError}</InlineBanner>
|
||||
) : null}
|
||||
|
|
@ -2023,7 +2035,23 @@ export function ConnectionSetupFlow({
|
|||
const zapierEntry = zapierSource
|
||||
? galleryQuery.data?.apps.find((app) => app.slug === "zapier") ?? null
|
||||
: null;
|
||||
const stepLabels = zapierSource
|
||||
const reconnectAiMethod = reconnectConnection?.connectionPurpose === "ai"
|
||||
? aiConnectionMetadataSchema.safeParse(reconnectConnection.config?.ai).data
|
||||
: undefined;
|
||||
const aiMethod = reconnectAiMethod ?? entry?.methods.find(method => method.key === connectionMethodKey)?.ai
|
||||
?? (!connectionMethodKey && entry?.methods.every(method => method.ai) ? entry.methods[0]?.ai : undefined);
|
||||
const credentialStep = entry ? renderCredentialStep?.({ app: entry, name: galleryName || entry.name, grantKind: effectiveGrantKind, agentIds: [...installAgentIds], allAgents: installChoice === "all", onBack: () => setAppStep("access") }) ?? (aiMethod && selectedCompanyId ? <><AiConnectionCredentialStep
|
||||
companyId={selectedCompanyId} provider={aiMethod.provider} fixedMethod={Boolean(aiConnection)} initialMethod={reconnectConnection?.connectionPurpose === "ai" ? (reconnectConnection.config?.ai as { method: "subscription" | "api_key" }).method : aiMethod.method}
|
||||
connectionId={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.id : undefined}
|
||||
name={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.name : galleryName || `My ${entry.name} ${aiMethod.method === "subscription" ? "subscription" : "API"}`}
|
||||
ownership={(reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.credentialPolicy === "shared" : effectiveGrantKind === "organization") ? "shared" : "personal"}
|
||||
agentIds={[...installAgentIds]} allAgents={installChoice === "all"}
|
||||
onCancel={() => onCancel ? onCancel() : navigate("/apps")}
|
||||
onComplete={result => { onComplete?.({ connectionId: result.connectionId }); if (!onComplete) navigate(`/apps/${result.connectionId}/permissions`); }}
|
||||
/></> : undefined) : undefined;
|
||||
const stepLabels = reconnectConnection?.connectionPurpose === "ai" ? ["Reconnect account"] : credentialStep !== undefined
|
||||
? ["Access", "Connect account"]
|
||||
: zapierSource
|
||||
? ZAPIER_STEP_LABELS
|
||||
: entry && setupCredentialSourceMethods.length > 1
|
||||
? ["Access", "Choose connection"]
|
||||
|
|
@ -2055,7 +2083,7 @@ export function ConnectionSetupFlow({
|
|||
? `Continue to ${entry?.name ?? "sign-in"}`
|
||||
: accessStepAuthKind === "oauth" ? "Continue" : "Save and continue";
|
||||
|
||||
const stepIndex = (zapierSource || entry) && step !== "gallery" && step !== "success"
|
||||
const stepIndex = reconnectConnection?.connectionPurpose === "ai" ? 0 : (zapierSource || entry) && step !== "gallery" && step !== "success"
|
||||
? SELECTED_APP_STEP_INDEX[step]
|
||||
: step === "success"
|
||||
? stepLabels.length
|
||||
|
|
@ -2188,7 +2216,7 @@ export function ConnectionSetupFlow({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : step === "key" && entry ? (
|
||||
) : step === "key" && entry && credentialStep !== undefined ? credentialStep : step === "key" && entry ? (
|
||||
<KeyStep
|
||||
entry={entry}
|
||||
error={connectMutation.isError ? (connectMutation.error instanceof Error ? connectMutation.error.message : "Please check your key and try again.") : null}
|
||||
|
|
@ -2347,7 +2375,7 @@ export function ConnectionSetupFlow({
|
|||
continuesToProvider={accessContinuesToProvider}
|
||||
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
|
||||
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
|
||||
pending={connectMutation.isPending || oauthStartMutation.isPending}
|
||||
pending={connectMutation.isPending || oauthStartMutation.isPending || Boolean(requestedAppKey && !entry)}
|
||||
onBack={backToGallery}
|
||||
onContinue={() => {
|
||||
if (directOAuthEntry) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
buildAssistantPartsFromTranscript,
|
||||
buildIssueChatMessages,
|
||||
isCoTSegmentActive,
|
||||
isRedundantAiRecoveryNotice,
|
||||
preserveReadableStreamingRetraction,
|
||||
stabilizeThreadMessages,
|
||||
type IssueChatComment,
|
||||
|
|
@ -1792,3 +1793,18 @@ describe("stabilizeThreadMessages", () => {
|
|||
expect(secondStable.messages).toBe(firstStable.messages);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("AI recovery presentation", () => {
|
||||
it.each(["pending", "accepted", "rejected", "expired"] as const)("replaces diagnostic notices with the same-run %s connection card", (status) => {
|
||||
const interaction: ConnectionIntentInteraction = { ...pendingConnectionIntentInteraction, status, sourceRunId: "failed-run", payload: { ...pendingConnectionIntentInteraction.payload, purpose: "ai" } };
|
||||
const notice = createComment({ authorType: "system", presentation: { kind: "system_notice", title: "AI connection needs attention", tone: "danger", detailsDefaultOpen: false }, metadata: { version: 1, sourceRunId: "failed-run", sections: [] } });
|
||||
expect(isRedundantAiRecoveryNotice(notice, [interaction])).toBe(true);
|
||||
expect(isRedundantAiRecoveryNotice(notice, [{ ...interaction, sourceRunId: "other-run" }])).toBe(false);
|
||||
expect(isRedundantAiRecoveryNotice(notice, [])).toBe(false);
|
||||
expect(isRedundantAiRecoveryNotice(notice, [{ ...interaction, payload: { ...interaction.payload, purpose: undefined } }])).toBe(false);
|
||||
const messages = buildIssueChatMessages({ comments: [notice], interactions: [interaction], timelineEvents: [], linkedRuns: [], liveRuns: [] });
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.metadata.custom).toMatchObject({ kind: "interaction" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1141,6 +1141,20 @@ function createLiveRunMessage(args: {
|
|||
return message;
|
||||
}
|
||||
|
||||
/** The durable AI interaction owns repair and its receipt; don't also show the
|
||||
* escalation's diagnostic card for that same failure. Keep unmatched notices. */
|
||||
export function isRedundantAiRecoveryNotice(
|
||||
comment: IssueChatComment,
|
||||
interactions: readonly IssueThreadInteraction[] = [],
|
||||
): boolean {
|
||||
return comment.presentation?.kind === "system_notice"
|
||||
&& ["AI connection needs attention", "Configuration incomplete"].includes(comment.presentation.title ?? "")
|
||||
&& Boolean(comment.metadata?.sourceRunId)
|
||||
&& interactions.some((interaction) => interaction.kind === "connection_intent"
|
||||
&& interaction.payload.purpose === "ai"
|
||||
&& interaction.sourceRunId === comment.metadata?.sourceRunId);
|
||||
}
|
||||
|
||||
export function buildIssueChatMessages(args: {
|
||||
comments: readonly IssueChatComment[];
|
||||
interactions?: readonly IssueThreadInteraction[];
|
||||
|
|
@ -1181,6 +1195,7 @@ export function buildIssueChatMessages(args: {
|
|||
const orderedMessages: MessageWithOrder[] = [];
|
||||
|
||||
for (const comment of sortByCreated(comments)) {
|
||||
if (isRedundantAiRecoveryNotice(comment, interactions)) continue;
|
||||
orderedMessages.push({
|
||||
createdAtMs: toTimestamp(comment.createdAt),
|
||||
order: 1,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,25 @@
|
|||
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
|
||||
import type { AiConnectionBinding, AiManagedConnectionSummary, AiProvider, CompanySecret, EnvBinding } from "@paperclipai/shared";
|
||||
import type { MyUserSecretEntry } from "../api/secrets";
|
||||
|
||||
export interface SavedProviderKey {
|
||||
id: string;
|
||||
label: string;
|
||||
binding: EnvBinding;
|
||||
export type SavedProviderKey = { id: string; label: string } & (
|
||||
| { binding: EnvBinding; aiConnection?: never }
|
||||
| { binding?: never; aiConnection: AiConnectionBinding }
|
||||
);
|
||||
|
||||
export function savedManagedProviderAccounts(
|
||||
companyId: string, provider: AiProvider, currentUserId: string,
|
||||
connections: AiManagedConnectionSummary[],
|
||||
): SavedProviderKey[] {
|
||||
return connections.flatMap<SavedProviderKey>((account) => {
|
||||
if (account.companyId !== companyId || account.provider !== provider || account.status !== "connected") return [];
|
||||
if (account.ownership === "personal" && account.ownerUserId === currentUserId && account.isDefault) {
|
||||
return [{ id: `ai:${account.grantId}`, label: `${account.name} (Your default)`, aiConnection: { provider, method: account.method, mode: "responsible_user" as const } }];
|
||||
}
|
||||
if (account.ownership === "shared") {
|
||||
return [{ id: `ai:${account.grantId}`, label: `${account.name} (Company shared)`, aiConnection: { provider, method: account.method, mode: "shared" as const, connectionId: account.id, grantId: account.grantId } }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Match the canonical onboarding key and distinct keys created by agent setup. */
|
||||
|
|
|
|||
|
|
@ -6,16 +6,18 @@ import { agentsApi } from "../api/agents";
|
|||
* the adapter's existing read-only CLI hello probe before calling setup connected. */
|
||||
export async function testAgentSetup(input: {
|
||||
companyId: string;
|
||||
agentId?: string;
|
||||
adapterType: string;
|
||||
providerAdapter: string;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
agentId?: string;
|
||||
aiConnection?: import("@paperclipai/shared").AiConnectionBinding;
|
||||
testCredentials?: Record<string, string>;
|
||||
environmentId: string | null;
|
||||
}): Promise<AdapterEnvironmentTestResult> {
|
||||
const payload = {
|
||||
adapterConfig: input.adapterConfig,
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
...(input.aiConnection ? { aiConnection: input.aiConnection } : {}),
|
||||
adapterConfig: input.adapterConfig,
|
||||
...(input.testCredentials ? { testCredentials: input.testCredentials } : {}),
|
||||
environmentId: input.environmentId,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { TaskChatProjectCreatedCard } from "@/components/task-chat/TaskChatProjectCreatedCard";
|
||||
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
|
||||
import { AiConnectionDesignExamples } from "@/components/ai-connections/AiConnectionDesignExamples";
|
||||
import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect";
|
||||
import { RepositoryEditor } from "@/components/RepositoryEditor";
|
||||
import { TaskChatRunnerActivityGroup } from "@/components/task-chat/TaskChatRunnerActivityGroup";
|
||||
|
|
@ -2343,6 +2344,10 @@ export function DesignGuide() {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="AI Connections">
|
||||
<AiConnectionDesignExamples />
|
||||
</Section>
|
||||
|
||||
<Section title="Built-in Agent Lifecycle Chips">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A derived lifecycle chip (amber) for attention states. The lifecycle chip is separate from
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ const state = vi.hoisted(() => ({
|
|||
navigate: vi.fn(),
|
||||
openNewIssue: vi.fn(),
|
||||
}));
|
||||
const managedApi = vi.hoisted(() => ({
|
||||
list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })),
|
||||
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
|
||||
}));
|
||||
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi }));
|
||||
vi.mock("@/api/agents", () => ({ agentsApi: api }));
|
||||
vi.mock("@/api/environments", () => ({ environmentsApi: envApi }));
|
||||
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings }));
|
||||
|
|
@ -287,9 +292,9 @@ describe("New agent setup", () => {
|
|||
expect(api.hire.mock.calls[0][1].adapterConfig.apiKey).toMatchObject({ type: "secret_ref", secretId: "org-secret-1" });
|
||||
expect(JSON.stringify(api.hire.mock.calls)).not.toContain("hermes-test-key");
|
||||
});
|
||||
it("shows Grok login guidance and hides ignored Kimi and OpenCode effort controls", async () => {
|
||||
it("uses the shared Grok connection flow and hides ignored Kimi and OpenCode effort controls", async () => {
|
||||
await render("grok_local");
|
||||
expect(container.textContent).toContain("grok login");
|
||||
expect(container.textContent).toContain("Connect Atlas to Grok");
|
||||
await render("opencode_local");
|
||||
expect(container.querySelector('[aria-label="Thinking effort"]')).toBeNull();
|
||||
});
|
||||
|
|
@ -374,16 +379,20 @@ describe("New agent setup", () => {
|
|||
["codex_local", "codex", "OpenAI", "OPENAI_API_KEY"],
|
||||
["paperclip_runner", "claude", "Claude", "ANTHROPIC_API_KEY"],
|
||||
["paperclip_runner", "codex", "OpenAI", "OPENAI_API_KEY"],
|
||||
])("stores %s %s API credentials only when finishing", async (adapter, runner, provider, key) => {
|
||||
])("stores %s %s as a reusable connection before hiring", async (adapter, runner, provider, key) => {
|
||||
await render(adapter, runner);
|
||||
await click("Use API key insteadUse subscription insteadUse API key instead");
|
||||
await click(provider + "API");
|
||||
await fill("API key", "connection-key");
|
||||
await click("Connect");
|
||||
expect(api.testEnvironment.mock.calls[0][2].testCredentials).toEqual({ [key]: "connection-key" });
|
||||
const binding = { provider: key === "ANTHROPIC_API_KEY" ? "anthropic" : "openai", method: "api_key", mode: "responsible_user" };
|
||||
expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ apiKey: "connection-key", provider: binding.provider }));
|
||||
expect(api.testEnvironment.mock.calls[0][2].testCredentials).toEqual({});
|
||||
expect(api.testEnvironment.mock.calls[0][2].aiConnection).toEqual(binding);
|
||||
expect(secrets.createUserSecretDefinition).not.toHaveBeenCalled();
|
||||
await click("Finish setup");
|
||||
expect(api.hire.mock.calls[0][1].adapterConfig.env[key].type).toBe("user_secret_ref");
|
||||
expect(api.hire.mock.calls[0][1].runtimeConfig.aiConnection).toEqual(binding);
|
||||
expect(managedApi.create).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.stringify(api.hire.mock.calls)).not.toContain("connection-key");
|
||||
});
|
||||
it.each([
|
||||
|
|
@ -409,7 +418,7 @@ describe("New agent setup", () => {
|
|||
expect(secrets.createMyUserSecret).not.toHaveBeenCalled();
|
||||
expect(secrets.rotateMyUserSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["opencode_local", "pi_local"])(
|
||||
it.each(["pi_local"])(
|
||||
"persists %s OpenRouter credentials only as a secret reference",
|
||||
async (adapter) => {
|
||||
await render(adapter);
|
||||
|
|
@ -434,6 +443,46 @@ describe("New agent setup", () => {
|
|||
expect(secrets.create).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
it("connects OpenRouter before testing and hiring OpenCode without copying credentials into the agent", async () => {
|
||||
await render("opencode_local");
|
||||
const model = "openrouter/anthropic/claude-sonnet-4.6";
|
||||
await fill("Model", model);
|
||||
await click("Connect another account");
|
||||
const dialog = document.querySelector('[role="dialog"]')!;
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
expect(api.testEnvironment).not.toHaveBeenCalled();
|
||||
const input = dialog.querySelector('[aria-label="API key"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "example-test-secret");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
const connectButton = [...dialog.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Connect")!;
|
||||
expect(connectButton.disabled).toBe(false);
|
||||
await act(async () => connectButton.click());
|
||||
await settle();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
provider: "openrouter", method: "api_key", apiKey: "example-test-secret",
|
||||
}));
|
||||
const binding = { provider: "openrouter", method: "api_key", mode: "responsible_user" };
|
||||
await click("Run test");
|
||||
expect(api.testEnvironment.mock.calls[0][2]).toEqual(expect.objectContaining({
|
||||
aiConnection: binding, testCredentials: {},
|
||||
adapterConfig: expect.objectContaining({ model }),
|
||||
}));
|
||||
await click("Finish setup");
|
||||
expect(api.hire.mock.calls[0][1]).toEqual(expect.objectContaining({
|
||||
adapterType: "opencode_local",
|
||||
runtimeConfig: expect.objectContaining({ aiConnection: binding }),
|
||||
adapterConfig: expect.objectContaining({ model }),
|
||||
}));
|
||||
expect(managedApi.create).toHaveBeenCalledTimes(1);
|
||||
expect(secrets.create).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(api.testEnvironment.mock.calls)).not.toContain("example-test-secret");
|
||||
expect(JSON.stringify(api.hire.mock.calls)).not.toContain("example-test-secret");
|
||||
});
|
||||
it.each(["codex", "claude", "opencode"])(
|
||||
"uses the correct native %s runner",
|
||||
async (runner) => {
|
||||
|
|
|
|||
|
|
@ -1337,6 +1337,14 @@ describe("AppDetail", () => {
|
|||
.find((button) => button.textContent?.trim() === label);
|
||||
}
|
||||
|
||||
it("does not label a revoked AI credential as Connected", async () => {
|
||||
getConnectionMock.mockResolvedValue(connection({ connectionPurpose: "ai", transport: "runtime_auth", healthStatus: "ok", config: { provider: "openai", method: "api_key" } }));
|
||||
listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1" }, grants: [organizationGrant({ status: "revoked" })], capabilities: fullCapabilities(), currentUserId: "user-1", members: [] });
|
||||
await renderAppDetail();
|
||||
expect(container.textContent).toContain("Revoked");
|
||||
expect(container.textContent).not.toContain("Connected");
|
||||
});
|
||||
|
||||
it("keeps the app header concise on every tab", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
getConnectionMock.mockResolvedValue(perUserConnection());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ManagedAiConnectionDetails } from "@/components/ai-connections/ManagedAiConnectionDetails";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { EmailConnectionAccess } from "@/components/EmailConnectionAccess";
|
||||
import { EmailConnectionInboxes } from "./chat/EmailEndpointSetup";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Loader2, Pencil } from "lucide-react";
|
||||
import type {
|
||||
|
|
@ -12,6 +13,7 @@ import type {
|
|||
import {
|
||||
connectionDisplaySecondaryHint,
|
||||
humanizeConnectionDisplayName,
|
||||
aiSubscriptionNeedsIsolatedLogin,
|
||||
isToolConnectionAttentionHealth as isAttentionHealthStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { Navigate, useParams, useNavigate, useSearchParams } from "@/lib/router";
|
||||
|
|
@ -60,7 +62,10 @@ import {
|
|||
|
||||
export { connectionAddress, connectionTransportLabel };
|
||||
|
||||
export function AppDetail() {
|
||||
export function AppDetail({ renderActions, onReconnect }: {
|
||||
renderActions?: (connection: ToolConnection) => ReactNode;
|
||||
onReconnect?: (connection: ToolConnection) => void;
|
||||
} = {}) {
|
||||
const { connectionId = "", tab } = useParams<{ connectionId: string; tab?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
|
@ -235,17 +240,18 @@ export function AppDetail() {
|
|||
() => installStateFrom(installsQuery.data?.installs ?? connection?.installs),
|
||||
[connection?.installs, installsQuery.data?.installs],
|
||||
);
|
||||
const access = useMemo(() => accessFrom(profile, install), [profile, install]);
|
||||
const access = useMemo(() => accessFrom(connection?.connectionPurpose === "ai" ? undefined : profile, install), [connection?.connectionPurpose, profile, install]);
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const [pending, setPending] = useState(false);
|
||||
const persist = useMutation({
|
||||
mutationFn: (next: {
|
||||
mutationFn: async (next: {
|
||||
enabled: Set<string>;
|
||||
askFirst: Set<string>;
|
||||
access: AccessDraft;
|
||||
reviewed?: Set<string>;
|
||||
}) =>
|
||||
toolsApi.finishApp(selectedCompanyId!, connectionId, {
|
||||
}) => connection?.connectionPurpose === "ai"
|
||||
? toolsApi.putConnectionInstalls(connectionId, next.access.mode === "all" ? [{ targetType: "company", targetId: selectedCompanyId! }] : [...next.access.agentIds].map(targetId => ({ targetType: "agent" as const, targetId })))
|
||||
: toolsApi.finishApp(selectedCompanyId!, connectionId, {
|
||||
enabledCatalogEntryIds: [...next.enabled],
|
||||
askFirstCatalogEntryIds: [...next.askFirst].filter((id) => next.enabled.has(id)),
|
||||
...(next.reviewed ? { reviewedCatalogEntryIds: [...next.reviewed] } : {}),
|
||||
|
|
@ -253,6 +259,7 @@ export function AppDetail() {
|
|||
}),
|
||||
onMutate: () => setPending(true),
|
||||
onSuccess: () => {
|
||||
void installsQuery.refetch();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
|
||||
|
|
@ -477,14 +484,17 @@ export function AppDetail() {
|
|||
);
|
||||
}
|
||||
|
||||
const status = statusFor(connection);
|
||||
const aiGrantRevoked = connection.connectionPurpose === "ai"
|
||||
&& grantRows.length > 0 && grantRows.every((grant) => grant.status === "revoked");
|
||||
const status: StatusInfo = aiGrantRevoked ? { label: "Revoked", tone: "attention" } : statusFor(connection);
|
||||
const needsReconnect = connection.requiresReauthorization
|
||||
?? (status.tone === "attention" && connection.healthStatus !== "unknown");
|
||||
const quarantined = catalog.filter((e) => e.status === "quarantined");
|
||||
const active = catalog.filter((e) => e.status === "active");
|
||||
const readOnly = active.filter((e) => e.isReadOnly);
|
||||
const canChange = active.filter((e) => !e.isReadOnly);
|
||||
const actionCount = catalogQuery.data ? active.length : null;
|
||||
const actionsContent = renderActions?.(connection) ?? (connection.connectionPurpose === "ai" ? <ManagedAiConnectionDetails connection={connection} /> : undefined);
|
||||
const actionCount = actionsContent !== undefined ? null : catalogQuery.data ? active.length : null;
|
||||
const reviewLoading = catalogQuery.isLoading || profilesQuery.isLoading || policiesQuery.isLoading;
|
||||
const permissionsLoading = reviewLoading || installsQuery.isLoading || agentsQuery.isLoading;
|
||||
const reviewFailed = catalogQuery.isError || profilesQuery.isError || policiesQuery.isError;
|
||||
|
|
@ -529,6 +539,7 @@ export function AppDetail() {
|
|||
galleryEntry={logoEntry}
|
||||
canReconnect={canReconnect}
|
||||
reconnectUnavailableMessage={reconnectUnavailableMessage}
|
||||
onReconnect={onReconnect ? () => onReconnect(connection) : connection.connectionPurpose === "ai" ? () => navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&reconnect=${connection.id}`) : undefined}
|
||||
onReconnected={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
|
||||
|
|
@ -593,8 +604,8 @@ export function AppDetail() {
|
|||
setAudienceOpenGrantId(null);
|
||||
setAudienceError(null);
|
||||
}}
|
||||
onConnectAsMe={() => startPersonalAuth.mutate()}
|
||||
onConnectOrganization={() => startOAuth.mutate()}
|
||||
onConnectAsMe={() => onReconnect ? onReconnect(connection) : startPersonalAuth.mutate()}
|
||||
onConnectOrganization={() => onReconnect ? onReconnect(connection) : startOAuth.mutate()}
|
||||
onConnectAgent={(agentId) => startOAuth.mutate({ asAgentId: agentId })}
|
||||
onRefreshAccess={() => refreshGitHubAccess.mutate()}
|
||||
refreshAccessPending={refreshGitHubAccess.isPending}
|
||||
|
|
@ -602,12 +613,13 @@ export function AppDetail() {
|
|||
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
|
||||
/>
|
||||
<PermissionsPanel
|
||||
actions={actionsContent}
|
||||
connectionId={connectionId}
|
||||
capabilities={grantsQuery.data?.capabilities}
|
||||
appName={appName}
|
||||
agents={agents}
|
||||
access={access}
|
||||
install={install}
|
||||
install={connection.connectionPurpose === "ai" ? installStateFrom([]) : install}
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
quarantined={quarantined}
|
||||
|
|
@ -620,7 +632,7 @@ export function AppDetail() {
|
|||
? "Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls."
|
||||
: undefined
|
||||
}
|
||||
onSaveAccess={(next) => apply({ access: accessIncludingInstalls(next, install) })}
|
||||
onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" ? next : accessIncludingInstalls(next, install) })}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
|
|
@ -779,7 +791,7 @@ function statusFor(connection: ToolConnection): StatusInfo {
|
|||
if (connection.enabled === false || connection.status === "disabled") {
|
||||
return { label: "Paused", tone: "paused" };
|
||||
}
|
||||
if (isAttentionHealthStatus(connection.healthStatus)) {
|
||||
if (isAttentionHealthStatus(connection.healthStatus) || (connection.connectionPurpose === "ai" && (connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(connection.config)))) {
|
||||
return { label: "Needs attention", tone: "attention" };
|
||||
}
|
||||
return { label: "Connected", tone: "connected" };
|
||||
|
|
|
|||
|
|
@ -438,6 +438,27 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
// credential is entered.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("keeps the existing Anthropic tool method reachable alongside AI authentication", async () => {
|
||||
mockParams.appKey = "anthropic";
|
||||
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")] });
|
||||
await render();
|
||||
await passAccessStep();
|
||||
expect(container.textContent).toContain("How do you want to connect?");
|
||||
expect(radioContaining("Claude subscription")).toBeTruthy();
|
||||
expect(radioContaining("Claude API key")).toBeTruthy();
|
||||
await act(async () => radioContaining("Use an API key")!.click());
|
||||
await flushReact();
|
||||
const key = container.querySelector<HTMLInputElement>('input[type="password"]');
|
||||
expect(key).toBeTruthy();
|
||||
await act(async () => setInputValue(key!, "fixture-anthropic-tool-key"));
|
||||
await act(async () => buttonByText("Connect")!.click());
|
||||
await flushReact();
|
||||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
galleryKey: "anthropic", connectionMethodKey: "api-key",
|
||||
}));
|
||||
expect(container.textContent).not.toContain("Connect for tool access instead");
|
||||
});
|
||||
|
||||
it("asks for a GitHub identity and defaults to the current user and every agent", async () => {
|
||||
mockParams.appKey = "github";
|
||||
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ManagedAiConnectionRow } from "@/components/ai-connections/ManagedAiConnectionDetails";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
getAppDefinitionForUrl,
|
||||
getAppStoreDefinition,
|
||||
isToolConnectionAttentionHealth,
|
||||
aiSubscriptionNeedsIsolatedLogin,
|
||||
} from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
|
||||
|
|
@ -173,7 +175,7 @@ function connectionState(connection: ToolConnection): ConnectionState {
|
|||
message: "Agents can’t use this account right now.",
|
||||
};
|
||||
}
|
||||
if (isToolConnectionAttentionHealth(connection.healthStatus)) {
|
||||
if ((connection.connectionPurpose === "ai" && (connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(connection.config))) || isToolConnectionAttentionHealth(connection.healthStatus)) {
|
||||
return {
|
||||
kind: "attention",
|
||||
label: "Needs attention",
|
||||
|
|
@ -264,7 +266,7 @@ function accountActionHref(
|
|||
* surface. Connected providers sort first and expand in place to show every
|
||||
* account; unconnected providers retain the same catalog setup flows.
|
||||
*/
|
||||
export function Browse() {
|
||||
export function Browse({ renderAccountDetails = (connection) => connection.connectionPurpose === "ai" ? <ManagedAiConnectionRow connection={connection} /> : null }: { renderAccountDetails?: (connection: ToolConnection) => ReactNode } = {}) {
|
||||
const navigate = useNavigate();
|
||||
const preselectedChatAgentId =
|
||||
typeof window === "undefined"
|
||||
|
|
@ -676,6 +678,7 @@ export function Browse() {
|
|||
<div className="space-y-3" role="list" aria-label="Connector list">
|
||||
{visibleRows.map((row) => (
|
||||
<ConnectorCard
|
||||
renderAccountDetails={renderAccountDetails}
|
||||
key={row.key}
|
||||
row={row}
|
||||
allConnections={connectionsQuery.data?.connections ?? []}
|
||||
|
|
@ -740,6 +743,7 @@ export function Browse() {
|
|||
}
|
||||
|
||||
export function ConnectorCard({
|
||||
renderAccountDetails,
|
||||
row,
|
||||
allConnections,
|
||||
userProfileById,
|
||||
|
|
@ -748,6 +752,7 @@ export function ConnectorCard({
|
|||
preselectedAgentId,
|
||||
chatConnectorsEnabled,
|
||||
}: {
|
||||
renderAccountDetails?: (connection: ToolConnection) => ReactNode;
|
||||
row: ConnectorRowModel;
|
||||
allConnections: ToolConnection[];
|
||||
userProfileById: ReadonlyMap<string, ConnectionOwnerProfile>;
|
||||
|
|
@ -805,6 +810,7 @@ export function ConnectorCard({
|
|||
<div className="divide-y divide-border border-t border-border">
|
||||
{row.connections.map((connection) => (
|
||||
<ConnectionAccountRow
|
||||
details={renderAccountDetails?.(connection)}
|
||||
key={connection.id}
|
||||
row={row}
|
||||
connection={connection}
|
||||
|
|
@ -885,12 +891,14 @@ export function ConnectorCard({
|
|||
}
|
||||
|
||||
function ConnectionAccountRow({
|
||||
details,
|
||||
row,
|
||||
connection,
|
||||
owner,
|
||||
onNavigate,
|
||||
onRemove,
|
||||
}: {
|
||||
details?: ReactNode;
|
||||
row: ConnectorRowModel;
|
||||
connection: ToolConnection;
|
||||
owner: ConnectionOwnerProfile | null;
|
||||
|
|
@ -918,6 +926,7 @@ function ConnectionAccountRow({
|
|||
>
|
||||
{accountName}
|
||||
</button>
|
||||
{details}
|
||||
{state.message ? (
|
||||
<div
|
||||
className={
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export function appSupportsToolCatalogSetup(entry: AppDefinition | null | undefi
|
|||
entry && appSupportsCatalogSetup({
|
||||
...entry,
|
||||
methods: entry.methods.filter(
|
||||
(method) => (method.purpose ?? "tool") === "tool" && method.transport !== "chat_sdk",
|
||||
(method) => (method.purpose ?? "tool") !== "channel" && method.transport !== "chat_sdk",
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -71,7 +71,7 @@ export function canEnterAppsConnect(
|
|||
if (
|
||||
!chatConnectorsEnabled &&
|
||||
entry?.methods.some((method) => method.transport === "chat_sdk") &&
|
||||
!entry.methods.some((method) => (method.purpose ?? "tool") === "tool" && method.transport !== "chat_sdk")
|
||||
!entry.methods.some((method) => (method.purpose ?? "tool") !== "channel" && method.transport !== "chat_sdk")
|
||||
) return false;
|
||||
// A retained connection may belong to a provider hidden from fresh catalog
|
||||
// setup. Admit only known providers here; the setup flow then proves the
|
||||
|
|
|
|||
|
|
@ -141,12 +141,14 @@ export function ReconnectCard({
|
|||
connection,
|
||||
galleryEntry,
|
||||
onReconnected,
|
||||
onReconnect,
|
||||
canReconnect = true,
|
||||
reconnectUnavailableMessage,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
galleryEntry: AppDefinition | null;
|
||||
onReconnected: () => void;
|
||||
onReconnect?: () => void;
|
||||
canReconnect?: boolean;
|
||||
reconnectUnavailableMessage?: string;
|
||||
}) {
|
||||
|
|
@ -216,6 +218,8 @@ export function ReconnectCard({
|
|||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{reconnectUnavailableMessage ?? "You don't have permission to reconnect this identity."}
|
||||
</p>
|
||||
) : onReconnect ? (
|
||||
<Button size="sm" variant="outline" onClick={onReconnect}>Reconnect</Button>
|
||||
) : managedByVercel && !oauth ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="sm" variant="outline" asChild>
|
||||
|
|
|
|||
|
|
@ -591,6 +591,8 @@ export function RevokeGrantDialog({
|
|||
pending,
|
||||
isOwnIdentity,
|
||||
credentialPolicy,
|
||||
description,
|
||||
children,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
|
|
@ -599,6 +601,8 @@ export function RevokeGrantDialog({
|
|||
pending: boolean;
|
||||
isOwnIdentity: boolean;
|
||||
credentialPolicy: ToolConnectionCredentialPolicy;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
|
|
@ -621,8 +625,9 @@ export function RevokeGrantDialog({
|
|||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{body}</AlertDialogDescription>
|
||||
<AlertDialogDescription>{description ?? body}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{children}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending} autoFocus>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Ban, Check, FlaskConical, Loader2, RefreshCw, Search, ShieldQuestion } from "lucide-react";
|
||||
import type { Agent, ToolCatalogEntry, ToolConnectionCapabilities } from "@paperclipai/shared";
|
||||
import { useSearchParams } from "@/lib/router";
|
||||
|
|
@ -37,6 +37,7 @@ export function PermissionsPanel({
|
|||
refreshPending,
|
||||
capabilities,
|
||||
permissionChangeWarning,
|
||||
actions,
|
||||
}: Pick<
|
||||
AppDetailSectionProps,
|
||||
| "appName"
|
||||
|
|
@ -58,6 +59,8 @@ export function PermissionsPanel({
|
|||
refreshPending: boolean;
|
||||
capabilities: ToolConnectionCapabilities | undefined;
|
||||
permissionChangeWarning?: string;
|
||||
/** A credential-only connection can supply its account controls instead of tool actions. */
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const [searchParams] = useSearchParams();
|
||||
return (
|
||||
|
|
@ -70,7 +73,7 @@ export function PermissionsPanel({
|
|||
disabled={pending}
|
||||
onSave={onSaveAccess}
|
||||
/>
|
||||
<ActionsSection
|
||||
{actions !== undefined ? actions : <ActionsSection
|
||||
key={connectionId}
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
|
|
@ -87,7 +90,7 @@ export function PermissionsPanel({
|
|||
onSetPermission={onSetActionPermission}
|
||||
onReviewQuarantined={onReviewQuarantined}
|
||||
onRefreshActions={onRefreshActions}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,6 +307,11 @@ function installStorybookApiFixtures() {
|
|||
? Response.json({ secretId: "saved-claude-subscription", latestVersion: 1 })
|
||||
: new Response(null, { status: 404 });
|
||||
}
|
||||
if (/^\/api\/companies\/[^/]+\/ai-connections$/.test(url.pathname)) {
|
||||
return init?.method === "POST"
|
||||
? Response.json({ connectionId: "managed-storybook", grantId: "grant-storybook" })
|
||||
: Response.json({ currentUserId: "user-storybook", connections: [] });
|
||||
}
|
||||
if (/^\/api\/companies\/[^/]+\/me\/user-secrets$/.test(url.pathname)) {
|
||||
return Response.json(onboardingFixtureState.savedApiKeys ? ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"].map((key) => ({
|
||||
definition: { id: key, companyId: "company-storybook", key: `${key}.setup.storybook`, name: key === "ANTHROPIC_API_KEY" ? "My Claude key" : "My OpenAI key", status: "active" },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import type {
|
||||
AiConnectionBinding,
|
||||
AiConnectionRequirement,
|
||||
AiConnectionSummary,
|
||||
} from "@/components/ai-connections/model";
|
||||
|
||||
export const AI_REVIEW_REQUIREMENT: AiConnectionRequirement = {
|
||||
companyId: "ai-review-company",
|
||||
provider: "anthropic",
|
||||
method: "subscription",
|
||||
};
|
||||
export const AI_REVIEW_BINDING: AiConnectionBinding = {
|
||||
provider: "anthropic",
|
||||
method: "subscription",
|
||||
mode: "responsible_user",
|
||||
};
|
||||
export const AI_REVIEW_CONNECTIONS: AiConnectionSummary[] = [
|
||||
{
|
||||
id: "claude-dotta",
|
||||
grantId: "grant-dotta",
|
||||
companyId: "ai-review-company",
|
||||
provider: "anthropic",
|
||||
method: "subscription",
|
||||
name: "My Claude subscription",
|
||||
accountLabel: "dotta@example.test",
|
||||
ownership: "personal",
|
||||
ownerUserId: "dotta",
|
||||
ownerName: "Dotta",
|
||||
isDefault: true,
|
||||
status: "connected",
|
||||
},
|
||||
{
|
||||
id: "claude-second",
|
||||
grantId: "grant-second",
|
||||
companyId: "ai-review-company",
|
||||
provider: "anthropic",
|
||||
method: "subscription",
|
||||
name: "My research account",
|
||||
accountLabel: "research@example.test",
|
||||
ownership: "personal",
|
||||
ownerUserId: "dotta",
|
||||
ownerName: "Dotta",
|
||||
status: "connected",
|
||||
},
|
||||
{
|
||||
id: "claude-shared",
|
||||
grantId: "grant-shared",
|
||||
companyId: "ai-review-company",
|
||||
provider: "anthropic",
|
||||
method: "subscription",
|
||||
name: "Engineering Claude",
|
||||
accountLabel: "engineering@example.test",
|
||||
ownership: "shared",
|
||||
status: "connected",
|
||||
},
|
||||
{
|
||||
id: "claude-sam",
|
||||
grantId: "grant-sam",
|
||||
companyId: "ai-review-company",
|
||||
provider: "anthropic",
|
||||
method: "subscription",
|
||||
name: "Sam’s Claude subscription",
|
||||
accountLabel: "sam@example.test",
|
||||
ownership: "personal",
|
||||
ownerUserId: "sam",
|
||||
ownerName: "Sam",
|
||||
status: "connected",
|
||||
},
|
||||
{
|
||||
id: "openai-personal",
|
||||
grantId: "grant-openai",
|
||||
companyId: "ai-review-company",
|
||||
provider: "openai",
|
||||
method: "subscription",
|
||||
name: "My ChatGPT subscription",
|
||||
accountLabel: "dotta@example.test",
|
||||
ownership: "personal",
|
||||
ownerUserId: "dotta",
|
||||
ownerName: "Dotta",
|
||||
isDefault: true,
|
||||
status: "connected",
|
||||
},
|
||||
{
|
||||
id: "openai-api",
|
||||
grantId: "grant-openai-api",
|
||||
companyId: "ai-review-company",
|
||||
provider: "openai",
|
||||
method: "api_key",
|
||||
name: "Engineering OpenAI API",
|
||||
ownership: "shared",
|
||||
status: "connected",
|
||||
},
|
||||
{
|
||||
id: "claude-api",
|
||||
grantId: "grant-claude-api",
|
||||
companyId: "ai-review-company",
|
||||
provider: "anthropic",
|
||||
method: "api_key",
|
||||
name: "Claude API — staging",
|
||||
ownership: "shared",
|
||||
status: "needs_attention",
|
||||
},
|
||||
{
|
||||
id: "openrouter-api",
|
||||
grantId: "grant-openrouter",
|
||||
companyId: "ai-review-company",
|
||||
provider: "openrouter",
|
||||
method: "api_key",
|
||||
name: "OpenRouter research",
|
||||
ownership: "personal",
|
||||
ownerUserId: "dotta",
|
||||
ownerName: "Dotta",
|
||||
isDefault: true,
|
||||
status: "connected",
|
||||
},
|
||||
{
|
||||
id: "grok-personal",
|
||||
grantId: "grant-grok",
|
||||
companyId: "ai-review-company",
|
||||
provider: "xai",
|
||||
method: "subscription",
|
||||
name: "My Grok subscription",
|
||||
ownership: "personal",
|
||||
ownerUserId: "dotta",
|
||||
ownerName: "Dotta",
|
||||
isDefault: true,
|
||||
status: "expired",
|
||||
},
|
||||
{
|
||||
id: "grok-api",
|
||||
grantId: "grant-grok-api",
|
||||
companyId: "ai-review-company",
|
||||
provider: "xai",
|
||||
method: "api_key",
|
||||
name: "Grok API",
|
||||
ownership: "shared",
|
||||
status: "revoked",
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
import { AiReviewBoundary } from "./AiReviewFrame";
|
||||
import { AiConnectorPages } from "./AiConnectorPages";
|
||||
import { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ModelSourceTiles } from "@/components/onboarding/ModelSourceTiles";
|
||||
import {
|
||||
AiConnectionAuth,
|
||||
type AiAuthState,
|
||||
} from "@/components/ai-connections/AiConnectionAuth";
|
||||
import { AiConnectionPicker } from "@/components/ai-connections/AiConnectionPicker";
|
||||
import {
|
||||
AiConnectionLegacyNotice,
|
||||
} from "@/components/ai-connections/AiConnectionManagement";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
aiMethodLabel,
|
||||
bindingProblem,
|
||||
matchesAiRequirement,
|
||||
type AiConnectionBinding,
|
||||
type AiConnectionRequirement,
|
||||
type AiConnectionSummary,
|
||||
} from "@/components/ai-connections/model";
|
||||
import {
|
||||
AI_REVIEW_BINDING,
|
||||
AI_REVIEW_CONNECTIONS,
|
||||
AI_REVIEW_REQUIREMENT,
|
||||
} from "../fixtures/aiConnections";
|
||||
|
||||
export interface AiConnectionsReviewProps {
|
||||
host?: "onboarding" | "new_agent" | "settings" | "task" | "connections";
|
||||
initialStage?: "picker" | "auth" | "manage" | "legacy" | "providers";
|
||||
initialConnections?: AiConnectionSummary[];
|
||||
initialBinding?: AiConnectionBinding;
|
||||
requirement?: AiConnectionRequirement;
|
||||
currentUserId?: string;
|
||||
initialAuthState?: AiAuthState;
|
||||
failFirstAttempt?: boolean;
|
||||
readOnly?: boolean;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 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} />;
|
||||
return <AgentConnectionReview {...props} />;
|
||||
}
|
||||
|
||||
function AgentConnectionReview({
|
||||
host = "settings",
|
||||
initialStage = "picker",
|
||||
initialConnections = AI_REVIEW_CONNECTIONS,
|
||||
initialBinding,
|
||||
requirement: initialRequirement = AI_REVIEW_REQUIREMENT,
|
||||
currentUserId = "dotta",
|
||||
initialAuthState = { phase: "idle" },
|
||||
failFirstAttempt = false,
|
||||
readOnly,
|
||||
loading,
|
||||
error,
|
||||
}: AiConnectionsReviewProps) {
|
||||
const requirement = initialRequirement;
|
||||
const [connections, setConnections] = useState(initialConnections);
|
||||
const [binding, setBinding] = useState(
|
||||
initialBinding ?? {
|
||||
...AI_REVIEW_BINDING,
|
||||
provider: requirement.provider,
|
||||
method: requirement.method,
|
||||
},
|
||||
);
|
||||
const [stage, setStage] = useState<string>(
|
||||
host === "connections" && initialStage === "picker" ? "list" : initialStage,
|
||||
);
|
||||
const [auth, setAuth] = useState<AiAuthState>(initialAuthState);
|
||||
const [name] = useState(
|
||||
`My ${aiMethodLabel(requirement.provider, requirement.method) === "API key" ? `${AI_PROVIDERS[requirement.provider].name} API` : aiMethodLabel(requirement.provider, requirement.method)}`,
|
||||
);
|
||||
const [tested, setTested] = useState(false);
|
||||
const [adopting, setAdopting] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [hasFailed, setHasFailed] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState(error);
|
||||
const returnFocus = useRef<HTMLElement | null>(null);
|
||||
const returnFocusLabel = useRef<string | null>(null);
|
||||
const region = useRef<HTMLDivElement>(null);
|
||||
const problem = bindingProblem(
|
||||
binding,
|
||||
requirement,
|
||||
connections,
|
||||
currentUserId,
|
||||
"nova",
|
||||
);
|
||||
const titles = {
|
||||
onboarding: "Connect your model provider",
|
||||
new_agent: "Connect Nova",
|
||||
settings: "Nova · Configuration",
|
||||
task: "Nova needs an AI connection",
|
||||
connections: "Connections",
|
||||
};
|
||||
const runtime =
|
||||
requirement.provider === "openai"
|
||||
? ["Codex", "Configured OpenAI model"]
|
||||
: requirement.provider === "anthropic"
|
||||
? ["Claude Code", "Configured Claude model"]
|
||||
: requirement.provider === "xai"
|
||||
? ["Grok Build", "Configured Grok model"]
|
||||
: ["OpenCode", "Configured OpenRouter model"];
|
||||
|
||||
function restoreFocus() {
|
||||
requestAnimationFrame(() => {
|
||||
const target = returnFocus.current?.isConnected
|
||||
? returnFocus.current
|
||||
: Array.from(
|
||||
region.current?.querySelectorAll<HTMLButtonElement>("button") ?? [],
|
||||
).find((button) => button.textContent === returnFocusLabel.current);
|
||||
target?.focus();
|
||||
});
|
||||
}
|
||||
function openAuth() {
|
||||
returnFocus.current = document.activeElement as HTMLElement;
|
||||
returnFocusLabel.current = returnFocus.current?.textContent ?? null;
|
||||
setAuth({ phase: "idle" });
|
||||
setStage("auth");
|
||||
}
|
||||
function connected() {
|
||||
if (failFirstAttempt && !hasFailed) {
|
||||
setHasFailed(true);
|
||||
setAuth({
|
||||
phase: "error",
|
||||
message:
|
||||
"The provider could not verify this account. Check your credentials and try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const id = `review-account-${connections.length + 1}`;
|
||||
const connection: AiConnectionSummary = {
|
||||
...requirement, id, grantId: `grant-${id}`, name: name.trim(),
|
||||
ownership: "personal", ownerUserId: currentUserId,
|
||||
ownerName: currentUserId === "dotta" ? "Dotta" : "Sam",
|
||||
isDefault: !connections.some((row) => matchesAiRequirement(row, requirement) && row.ownerUserId === currentUserId && row.isDefault),
|
||||
status: "connected",
|
||||
};
|
||||
setConnections((rows) => [...rows, connection]);
|
||||
setAuth({ phase: "connected" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6" ref={region}>
|
||||
<aside
|
||||
className="flex flex-col gap-2 rounded-lg bg-muted p-3 text-xs text-muted-foreground"
|
||||
aria-label="Storybook simulator"
|
||||
>
|
||||
<span>
|
||||
Storybook-only controls · These simulate provider responses and do not appear in the app.
|
||||
</span>
|
||||
{stage === "auth" &&
|
||||
auth.phase === "waiting" &&
|
||||
requirement.provider !== "anthropic" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="self-start"
|
||||
onClick={connected}
|
||||
>
|
||||
Simulate provider completion
|
||||
</Button>
|
||||
)}
|
||||
{stage === "auth" &&
|
||||
(auth.phase === "waiting" || auth.phase === "starting") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="self-start"
|
||||
onClick={() =>
|
||||
setAuth({
|
||||
phase: "expired",
|
||||
message:
|
||||
"This sign-in attempt expired. Start again to receive a new code.",
|
||||
})
|
||||
}
|
||||
>
|
||||
Simulate expired attempt
|
||||
</Button>
|
||||
)}
|
||||
</aside>
|
||||
<div className="space-y-3 rounded-lg border border-dashed border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">Example page context · Storybook only</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold">{titles[host]}</h2>
|
||||
{host === "onboarding" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect → Configure agent → First task
|
||||
</p>
|
||||
)}
|
||||
{host === "task" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect an account for the responsible user to continue this task.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{host !== "connections" && (
|
||||
<dl className="flex flex-wrap gap-6 text-sm" aria-label="Agent runtime">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Harness</dt>
|
||||
<dd data-testid="ai-harness">{runtime[0]}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Model</dt>
|
||||
<dd data-testid="ai-model">{runtime[1]}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
{stage === "list" && <AiConnectorPages initialConnections={connections} />}
|
||||
{stage === "legacy" && (
|
||||
<AiConnectionLegacyNotice
|
||||
readOnly={readOnly}
|
||||
onAdopt={() => {
|
||||
setAdopting(true);
|
||||
setStage("picker");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{stage === "picker" && (
|
||||
<>
|
||||
{adopting && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Confirm this account’s ownership and use, then test it before
|
||||
replacing existing authentication.
|
||||
</p>
|
||||
)}
|
||||
<AiReviewBoundary label="App component: AiConnectionPicker">
|
||||
<AiConnectionPicker
|
||||
requirement={requirement}
|
||||
connections={connections}
|
||||
value={binding}
|
||||
currentUserId={currentUserId}
|
||||
agentId="nova"
|
||||
agentName="Nova"
|
||||
loading={loading}
|
||||
error={connectionError}
|
||||
readOnly={readOnly}
|
||||
onRetry={() => setConnectionError(undefined)}
|
||||
onChange={(next) => {
|
||||
setBinding(next);
|
||||
setTested(false);
|
||||
setSaved(false);
|
||||
}}
|
||||
onConnect={() => openAuth()}
|
||||
/>
|
||||
</AiReviewBoundary>
|
||||
{!readOnly && !loading && !connectionError && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">Example form actions · Storybook only</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{adopting && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={Boolean(problem)}
|
||||
onClick={() => setTested(true)}
|
||||
>
|
||||
Test selected connection
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={Boolean(problem) || (adopting && !tested)}
|
||||
onClick={() => {
|
||||
setSaved(true);
|
||||
setStage("saved");
|
||||
}}
|
||||
>
|
||||
{adopting
|
||||
? "Adopt connection"
|
||||
: host === "settings"
|
||||
? "Save connection"
|
||||
: "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tested && (
|
||||
<p role="status" className="text-sm">
|
||||
Connection test passed for{" "}
|
||||
{currentUserId === "dotta" ? "Dotta" : "Sam"}. Harness and model
|
||||
are unchanged.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{stage === "auth" && (
|
||||
<>
|
||||
<ModelSourceTiles
|
||||
label="Model provider"
|
||||
sources={[
|
||||
{
|
||||
id: requirement.provider,
|
||||
label: AI_PROVIDERS[requirement.provider].name,
|
||||
icon: AI_PROVIDERS[requirement.provider].logo ? (
|
||||
<img
|
||||
src={AI_PROVIDERS[requirement.provider].logo}
|
||||
className={
|
||||
requirement.provider === "xai"
|
||||
? "size-6 dark:invert"
|
||||
: "size-6"
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
mode={requirement.method === "api_key" ? "api" : "subscription"}
|
||||
selectedId={requirement.provider}
|
||||
collapsed
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<AiReviewBoundary label="Simulated authentication controller: AiConnectionAuth · Reuses existing login cards">
|
||||
<AiConnectionAuth
|
||||
provider={requirement.provider}
|
||||
method={requirement.method}
|
||||
state={auth}
|
||||
onStart={() => {
|
||||
if (!name.trim()) return;
|
||||
setAuth({
|
||||
phase: "waiting",
|
||||
authorizationUrl: "#storybook-provider-simulator",
|
||||
code: "DEMO-CODE",
|
||||
});
|
||||
}}
|
||||
onSubmit={() => {
|
||||
if (name.trim()) connected();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setAuth({ phase: "cancelled" });
|
||||
setStage("picker");
|
||||
restoreFocus();
|
||||
}}
|
||||
onDone={() => {
|
||||
setStage("picker");
|
||||
restoreFocus();
|
||||
}}
|
||||
/>
|
||||
</AiReviewBoundary>
|
||||
</>
|
||||
)}
|
||||
{stage === "saved" && (
|
||||
<>
|
||||
<p role="status" className="text-sm">
|
||||
{saved
|
||||
? adopting
|
||||
? "Managed connection adopted."
|
||||
: "Connection selected for Nova."
|
||||
: "Connection saved."}{" "}
|
||||
Harness and model are unchanged.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The account remains in Connections even if you leave agent setup.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSaved(false);
|
||||
setAdopting(false);
|
||||
setStage("picker");
|
||||
}}
|
||||
>
|
||||
Create another agent using existing connections
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setStage("list")}>
|
||||
View Connections
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { BreadcrumbBar } from "@/components/BreadcrumbBar";
|
||||
import { AiReviewBoundary } from "./AiReviewFrame";
|
||||
import { AiConnectionAccountControls } from "@/components/ai-connections/AiConnectionAccountControls";
|
||||
import { useEffect, useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Route, Routes, useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { APP_DEFINITIONS, getAppStoreDefinition, type AppDefinition, type ToolApplication, type ToolConnection, type ConnectionGrantsResponse } from "@paperclipai/shared";
|
||||
import { Browse } from "@/pages/apps/Browse";
|
||||
import { AppDetail } from "@/pages/apps/AppDetail";
|
||||
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AiConnectionAuth, type AiAuthState } from "@/components/ai-connections/AiConnectionAuth";
|
||||
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";
|
||||
|
||||
const companyId = "company-storybook";
|
||||
const date = new Date("2026-09-10T12:00:00Z");
|
||||
const agents = storybookAgents.slice(0, 2).map((agent, index) => ({ ...agent, id: index ? "atlas" : "nova", name: index ? "Atlas" : "Nova" }));
|
||||
const capabilities = { canConfigure: true, canCreateOrganizationGrant: true, canSetCompanyInstall: true, canConnectAsCurrentUser: true, canManageAgentInstalls: true, canViewOtherPersonalIdentities: true, editableAgentIds: ["nova", "atlas"] };
|
||||
const members = [{ userId: "dotta", name: "Dotta", email: "dotta@example.test" }, { userId: "sam", name: "Sam", email: "sam@example.test" }];
|
||||
|
||||
/** Use the production provider catalog; only accounts and actions are simulated. */
|
||||
const gallery: AppDefinition[] = (Object.keys(AI_PROVIDERS) as AiProvider[]).map((provider) => getAppStoreDefinition(provider)!);
|
||||
for (const slug of ["github", "gmail"]) {
|
||||
const app = getAppStoreDefinition(slug);
|
||||
if (app) gallery.push(app);
|
||||
}
|
||||
function asConnection(account: AiConnectionSummary): ToolConnection {
|
||||
return {
|
||||
id: account.id, companyId, applicationId: `app-${account.provider}`, name: account.name, uid: account.id,
|
||||
connectionKind: "managed", ownership: "customer", connectionPurpose: "ai", transport: "runtime_auth", authKind: account.method === "subscription" ? "oauth" : "api_key",
|
||||
credentialSource: "paperclip_vault", credentialPolicy: account.ownership === "shared" ? "shared" : "per_user",
|
||||
status: account.status === "revoked" ? "disabled" : "active", enabled: account.status !== "revoked",
|
||||
transportConfig: {}, config: { sourceTemplateKey: account.provider, ai: { provider: account.provider, method: account.method }, aiIsolatedSubscription: true }, credentialSecretRefs: [],
|
||||
healthStatus: account.status === "connected" ? "ok" : "error", healthCheckedAt: date,
|
||||
healthMessage: account.status === "connected" ? null : "Sign in again to restore this account. No other account will be used.",
|
||||
lastError: account.status === "connected" ? null : "This account needs to be connected again.",
|
||||
createdByAgentId: null, createdByUserId: account.ownerUserId ?? "dotta", createdAt: date, updatedAt: date,
|
||||
};
|
||||
}
|
||||
function grantsFor(account: AiConnectionSummary, readOnly: boolean): ConnectionGrantsResponse {
|
||||
return { connection: { id: account.id, uid: account.id }, currentUserId: "dotta", members,
|
||||
capabilities: Object.fromEntries(Object.entries(capabilities).map(([key, value]) => [key, typeof value === "boolean" ? !readOnly && value : value])) as typeof capabilities,
|
||||
grants: [{ id: account.grantId, companyId, connectionId: account.id,
|
||||
kind: account.ownership === "shared" ? "organization" : "user", subjectUserId: account.ownerUserId ?? null,
|
||||
providerTenant: { name: account.accountLabel ?? account.name }, credentialSecretRefs: [],
|
||||
status: account.status === "connected" ? "active" : account.status === "revoked" ? "revoked" : "expired",
|
||||
isDefault: account.ownership === "shared", createdByAgentId: null, createdByUserId: account.ownerUserId ?? "dotta",
|
||||
revokedAt: null, revokedByAgentId: null, revokedByUserId: null, lastUsedAt: null, createdAt: date, updatedAt: date,
|
||||
members: [], capabilities: { canRevoke: !readOnly && (account.ownership === "shared" || account.ownerUserId === "dotta"), canEditAudience: !readOnly && account.ownership === "shared" },
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}) {
|
||||
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[]>() }));
|
||||
const [ready, setReady] = useState(false);
|
||||
const [, render] = useState(0);
|
||||
const navigate = useNavigate();
|
||||
function update(account: AiConnectionSummary) {
|
||||
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);
|
||||
}
|
||||
useEffect(() => {
|
||||
const previous = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
const request = input instanceof Request ? input : null;
|
||||
const url = new URL(request?.url ?? String(input), window.location.origin);
|
||||
const method = init?.method ?? request?.method ?? "GET";
|
||||
const payload = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
const path = url.pathname;
|
||||
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/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}/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);
|
||||
if (path === `/api/companies/${companyId}/user-directory`) return reply({ users: members.map((member) => ({ principalId: member.userId, status: "active", user: { name: member.name, email: member.email } })) });
|
||||
const match = path.match(/^\/api\/tool-connections\/([^/]+)(.*)$/);
|
||||
if (match) {
|
||||
const row = store.accounts.find((candidate) => candidate.id === match[1]);
|
||||
if (!row) return Response.json({ error: "Unknown review connection" }, { status: 404 });
|
||||
const suffix = match[2];
|
||||
if (!suffix) {
|
||||
if (method === "PATCH") update({ ...row, name: payload.name ?? row.name });
|
||||
if (method === "DELETE") store.removed.add(row.id);
|
||||
return reply(asConnection(store.accounts.find((candidate) => candidate.id === row.id)!));
|
||||
}
|
||||
if (suffix === "/grants") {
|
||||
const result = grantsFor(row, readOnly);
|
||||
result.grants[0].members = (store.audience.get(row.id) ?? []).map((userId) => ({ id: `member-${userId}`, companyId, grantId: row.grantId, subjectType: "user" as const, subjectId: userId, createdAt: date }));
|
||||
return reply(result);
|
||||
}
|
||||
if (method === "DELETE" && /^\/grants\/[^/]+$/.test(suffix)) { update({ ...row, status: "revoked" }); return reply(grantsFor({ ...row, status: "revoked" }, readOnly).grants[0]); }
|
||||
if (suffix.endsWith("/members")) { store.audience.set(row.id, payload.memberUserIds ?? []); return reply(grantsFor(row, readOnly).grants[0]); }
|
||||
if (suffix === "/installs") {
|
||||
if (method === "PUT") store.installs.set(row.id, payload.installs ?? []);
|
||||
return reply({ connectionId: row.id, installs: store.installs.get(row.id) ?? [] });
|
||||
}
|
||||
if (suffix === "/catalog") return reply({ catalog: [] });
|
||||
return Response.json({ error: `Unsupported review operation: ${suffix}` }, { status: 400 });
|
||||
}
|
||||
if (path.startsWith(`/api/companies/${companyId}/tools/apps/`) && path.endsWith("/finish")) {
|
||||
const id = path.split("/").at(-2)!;
|
||||
const profile = { id: `profile-${id}`, companyId, profileKey: `app:${id}`, entries: [], bindings: payload.access === "all_agents" ? [{ targetType: "company", targetId: companyId }] : (payload.access?.agentIds ?? []).map((targetId: string) => ({ targetType: "agent", targetId })) };
|
||||
store.profiles.set(id, profile);
|
||||
return reply({ connection: asConnection(rows.find((row) => row.id === id)!), profile, policy: null });
|
||||
}
|
||||
// Unhandled fixture mutations must never reach a live API/provider.
|
||||
if (method !== "GET" && path.startsWith("/api/")) return Response.json({ error: "This review does not perform live operations." }, { status: 400 });
|
||||
return previous(input, init);
|
||||
};
|
||||
navigate(detail ? `/PAP/apps/${initialConnections[0]?.id ?? "claude-dotta"}/permissions` : "/PAP/apps", { replace: true });
|
||||
setReady(true);
|
||||
return () => { window.fetch = previous; client.clear(); };
|
||||
}, []);
|
||||
if (!ready) return <p className="p-6">Loading Connectors review…</p>;
|
||||
return <QueryClientProvider client={client}>
|
||||
<main className="mx-auto max-w-5xl p-6">
|
||||
<p className="mb-6 text-xs text-muted-foreground">Existing app page components below · Fixture accounts · Review annotation</p>
|
||||
<BreadcrumbBar />
|
||||
<div className="pt-6">
|
||||
<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;
|
||||
}} />} />
|
||||
<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 });
|
||||
}}
|
||||
onReconnect={() => navigate(`/apps/connect?source=${account.provider}&stage=setup&reconnect=${account.id}`)}
|
||||
onRevoke={() => update({ ...account, status: "revoked" })}
|
||||
/></AiReviewBoundary> : undefined;
|
||||
}} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function Setup({ accounts, onSave }: { accounts: AiConnectionSummary[]; onSave: (account: AiConnectionSummary) => void }) {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const provider = (params.get("source") ?? "anthropic") as AiProvider;
|
||||
const reconnect = accounts.find((row) => row.id === params.get("reconnect"));
|
||||
const [method, setMethod] = useState<AiAuthMethod>(reconnect?.method ?? (provider === "openrouter" ? "api_key" : "subscription"));
|
||||
const [state, setState] = useState<AiAuthState>({ phase: "idle" });
|
||||
const [name, setName] = useState(reconnect?.name ?? `My ${AI_PROVIDERS[provider]?.subscriptionName ?? "OpenRouter API"}`);
|
||||
const [savedId, setSavedId] = useState<string>();
|
||||
function complete(grantKind: string, agentIds: string[], allAgents: boolean) {
|
||||
const id = reconnect?.id ?? `review-${provider}-${accounts.length}`;
|
||||
const personal = reconnect ? reconnect.ownership === "personal" : grantKind === "user";
|
||||
onSave(reconnect ? { ...reconnect, status: "connected" } : { id, grantId: `grant-${id}`, companyId, provider, method, name, ownership: personal ? "personal" : "shared", ownerUserId: personal ? "dotta" : undefined, ownerName: personal ? "Dotta" : undefined, status: "connected", isDefault: personal && !accounts.some((row) => row.ownerUserId === "dotta" && row.provider === provider && row.method === method && row.isDefault) });
|
||||
if (!reconnect) void fetch(`/api/companies/${companyId}/tools/apps/${id}/finish`, { method: "POST", body: JSON.stringify({ access: allAgents ? "all_agents" : { agentIds } }) });
|
||||
setSavedId(id); setState({ phase: "connected" });
|
||||
}
|
||||
if (!(provider in AI_PROVIDERS)) return <><p className="text-sm">This review focuses on AI authentication. The existing connector remains in the same list.</p><Button onClick={() => navigate("/apps")}>Back to Connectors</Button></>;
|
||||
return <ConnectionSetupFlow serviceSlug={provider} onCancel={() => navigate("/apps")} renderCredentialStep={({ grantKind, agentIds, allAgents }) => <AiReviewBoundary label="Shared AI credential presentation · Existing setup shell and login cards"><div className="mx-auto max-w-xl space-y-4">
|
||||
<label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(reconnect)} /></label>
|
||||
{!reconnect && provider !== "openrouter" && <CredentialModeLink mode={method === "subscription" ? "subscription" : "api"} onChange={() => { setMethod(method === "subscription" ? "api_key" : "subscription"); setState({ phase: "idle" }); }} />}
|
||||
<AiConnectionAuth provider={provider} method={method} state={state}
|
||||
onStart={() => setState({ phase: "waiting", authorizationUrl: "https://example.test/review-authorization", code: provider === "openai" ? "REVIEW-CODE" : undefined })}
|
||||
onSubmit={() => complete(grantKind, agentIds, allAgents)}
|
||||
onCancel={() => navigate("/apps")}
|
||||
onDone={() => navigate(`/apps/${savedId}/permissions`)} />
|
||||
{state.phase === "waiting" && method === "subscription" && provider !== "anthropic" && <Button variant="outline" onClick={() => complete(grantKind, agentIds, allAgents)}>Storybook only: simulate browser completion</Button>}
|
||||
</div></AiReviewBoundary>} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
/** Review annotations live only in Storybook; none of this frame ships in the app. */
|
||||
export function AiReviewFrame({ location, existing, proposed, wrapper, children }: {
|
||||
location: string;
|
||||
existing: string;
|
||||
proposed: string;
|
||||
wrapper: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <div className="mx-auto max-w-6xl p-4 sm:p-6" data-testid="ai-review-frame">
|
||||
<aside aria-label="Storybook context" className="mb-4 space-y-3 rounded-lg border border-dashed border-border bg-muted p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide">Storybook only · Review guide</p>
|
||||
<p className="text-sm"><strong>Intended app location:</strong> {location}</p>
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div><dt className="font-semibold">Already in the app</dt><dd className="mt-1 text-muted-foreground">{existing}</dd></div>
|
||||
<div><dt className="font-semibold">Integrated AI component</dt><dd className="mt-1 text-muted-foreground">{proposed}</dd></div>
|
||||
<div><dt className="font-semibold">Storybook simulation</dt><dd className="mt-1 text-muted-foreground">{wrapper}</dd></div>
|
||||
</dl>
|
||||
<p className="text-xs text-muted-foreground">Dashed frames and labels are review annotations. Accounts and provider responses are fixtures. The components are integrated in the app; these stories use simulated accounts and authentication.</p>
|
||||
</aside>
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-background" data-testid="ai-review-preview">{children}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** Marks a precise component boundary within a simulated page or an existing app page. */
|
||||
export function AiReviewBoundary({ label, children }: { label: string; children: ReactNode }) {
|
||||
return <div className="min-w-0 space-y-3 rounded-lg border border-dashed border-border p-3" data-testid="ai-component-boundary">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label} · Review annotation</p>
|
||||
{children}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function aiReviewContext(id: string, args: { host?: string; initialStage?: string }) {
|
||||
const story = id.replace("ai-connections-review--", "");
|
||||
if (story === "review-index") return {
|
||||
location: "This is a Storybook review index. It has no app location.",
|
||||
existing: "The linked stories identify the existing pages they use.",
|
||||
proposed: "The linked stories identify the new AI-specific components.",
|
||||
wrapper: "This entire index and its links exist only for review.",
|
||||
};
|
||||
if (story.startsWith("inline-task")) return {
|
||||
location: "A task → connection request card → Connect / Use existing modal.",
|
||||
existing: "ConnectionIntentInteractionBody: the task card, modal, reuse chooser and focus handling. ConnectionSetupFlow: access/setup steps.",
|
||||
proposed: "AI authentication content inside that existing setup flow.",
|
||||
wrapper: "The example task title, Nova, accounts and successful continuation are simulated. There is no live task or run.",
|
||||
};
|
||||
if (args.host === "connections" || story === "identity-matrix") {
|
||||
const detail = args.initialStage === "manage";
|
||||
return {
|
||||
location: detail ? "Connectors → choose an account → account permissions/details." : "Connectors (/:company/apps) → provider → Add account or open an account.",
|
||||
existing: detail ? "AppDetail: header/rename, ownership display and agent-access controls. BreadcrumbBar supplies existing page navigation. The existing revoke dialog and reconnect banner are reused." : "Browse: provider groups, account rows, search and menus. Navigation opens the existing AppDetail and ConnectionSetupFlow components.",
|
||||
proposed: detail ? "The marked AI account section: personal default and AI credential actions." : "AI provider/account fixture entries and their method/default labels. AI-specific sections are marked when you open an account or sign in.",
|
||||
wrapper: "This frame, page margins and in-memory API. The page components are real; the AI accounts and all changes are simulated.",
|
||||
};
|
||||
}
|
||||
const location = args.host === "onboarding" ? "Onboarding → existing provider connection step."
|
||||
: args.host === "new_agent" ? "Create agent → provider/harness configuration → AI connection field."
|
||||
: args.host === "task" ? "A task blocked on its responsible user’s AI credentials. This story isolates the picker; see Inline task connection for the real task host."
|
||||
: "Agent → configuration → AI connection field beside harness/model.";
|
||||
return {
|
||||
location,
|
||||
existing: "ConnectionChoiceList (extracted from connection setup), AppLogo, and the existing onboarding subscription/API-key cards and fields.",
|
||||
proposed: "The marked AI connection picker and authentication composition. The app uses these controls beside its harness/model settings.",
|
||||
wrapper: "The Nova heading, harness/model values, save/continue buttons and simulator controls form a mock page. They are not the real agent configuration form.",
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { AiReviewBoundary } from "./AiReviewFrame";
|
||||
import { useEffect, useState } from "react";
|
||||
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
||||
import { APP_DEFINITIONS, type ConnectionIntentInteraction } from "@paperclipai/shared";
|
||||
import { ConnectionIntentInteractionBody } from "@/features/connections/ConnectionIntentInteractionBody";
|
||||
import { ConnectionSetupFlow, type ConnectionSetupFlowProps } from "@/features/connections/ConnectionSetupFlow";
|
||||
import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import { AiConnectionAuth, type AiAuthState } from "@/components/ai-connections/AiConnectionAuth";
|
||||
import { AI_REVIEW_CONNECTIONS } from "../fixtures/aiConnections";
|
||||
import { storybookAgents } from "../fixtures/paperclipData";
|
||||
|
||||
const initial: ConnectionIntentInteraction = {
|
||||
...pendingConnectionIntentInteraction,
|
||||
id: "ai-task-request", companyId: "company-storybook", addresseeUserId: "dotta",
|
||||
payload: { version: 1, purpose: "ai", serviceSlug: "anthropic", serviceName: "Claude", serviceLogoUrl: "/brands/claude-color.svg", requestingAgentId: "nova", requestingAgentName: "Nova", phase: "requested" },
|
||||
};
|
||||
|
||||
/** The actual task connection request card, modal, reuse flow and focus lifecycle. */
|
||||
export function AiTaskConnectionReview({ reuse = false }: { reuse?: boolean }) {
|
||||
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } }));
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
let interaction = initial;
|
||||
const previous = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input), window.location.origin);
|
||||
const path = url.pathname;
|
||||
const app = APP_DEFINITIONS.find((entry) => entry.slug === "anthropic")!;
|
||||
const payload = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
if (path.startsWith("/api/connection-intents/ai-task-request/")) {
|
||||
if (path.endsWith("/complete")) interaction = { ...interaction, status: "accepted", result: { version: 1, outcome: "connected", connectionId: payload.connectionId } };
|
||||
if (path.endsWith("/decline")) interaction = { ...interaction, status: "rejected", result: { version: 1, outcome: "declined" } };
|
||||
if (path.endsWith("/phase")) interaction = { ...interaction, payload: { ...interaction.payload, phase: payload.phase } };
|
||||
return Response.json(path.endsWith("setup-options") ? {
|
||||
version: 1, interaction, service: { service: "anthropic", name: "Claude", methods: [], state: "needs_user_action", connectionId: null },
|
||||
aiConnection: { provider: "anthropic", method: "subscription", mode: "responsible_user" }, requestedAgentId: "nova", existingConnections: reuse ? [{ id: "claude-dotta", applicationId: "app-anthropic", name: AI_REVIEW_CONNECTIONS[0].name, status: "active", enabled: true }] : [],
|
||||
} : interaction);
|
||||
}
|
||||
if (path === "/api/companies/company-storybook/tools/gallery") return Response.json({ apps: [{ ...app, name: "Claude" }], capabilities: { canCreateOrganizationGrant: false, canSetCompanyInstall: false } });
|
||||
if (path === "/api/companies/company-storybook/agents") return Response.json([{ ...storybookAgents[0], id: "nova", name: "Nova" }]);
|
||||
if (path === "/api/ai-review-interactions") return Response.json([interaction]);
|
||||
return previous(input, init);
|
||||
};
|
||||
setReady(true);
|
||||
return () => { window.fetch = previous; client.clear(); };
|
||||
}, [client, reuse]);
|
||||
return ready ? <QueryClientProvider client={client}><TaskCard /></QueryClientProvider> : null;
|
||||
}
|
||||
function TaskCard() {
|
||||
const query = useQuery({ queryKey: ["issues", "interactions", "ai-review"], queryFn: async (): Promise<ConnectionIntentInteraction[]> => (await fetch("/api/ai-review-interactions")).json() });
|
||||
const interaction = query.data?.[0];
|
||||
return <main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<p className="text-xs text-muted-foreground">Example task context · Storybook only</p>
|
||||
<h1 className="text-lg font-semibold">Nova needs your Claude connection</h1>
|
||||
<p className="text-sm text-muted-foreground">Existing task connection request · Fixture data. Harness: Claude Code. Model: Configured Claude model.</p>
|
||||
{interaction && <AiReviewBoundary label="Existing app component: ConnectionIntentInteractionBody"><ConnectionIntentInteractionBody interaction={interaction} currentUserId="dotta" addresseeLabel="Dotta" renderSetup={(props) => <TaskSetup {...props} />} /></AiReviewBoundary>}
|
||||
</main>;
|
||||
}
|
||||
function TaskSetup(props: ConnectionSetupFlowProps) {
|
||||
const [state, setState] = useState<AiAuthState>({ phase: "idle" });
|
||||
return <ConnectionSetupFlow {...props} renderCredentialStep={() => <AiReviewBoundary label="Simulated authentication controller · Existing login cards"><AiConnectionAuth provider="anthropic" method="subscription" state={state}
|
||||
onStart={() => setState({ phase: "waiting", authorizationUrl: "https://example.test/review-login" })}
|
||||
onSubmit={() => setState({ phase: "connected" })}
|
||||
onCancel={() => props.onCancel?.()}
|
||||
onDone={() => props.onComplete?.({ connectionId: "review-task-claude" })}
|
||||
/></AiReviewBoundary>} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,525 @@
|
|||
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 { AiConnectionsReview } from "../prototypes/AiConnectionsReview";
|
||||
import { AiConnectorPages } from "../prototypes/AiConnectorPages";
|
||||
import {
|
||||
AI_REVIEW_BINDING,
|
||||
AI_REVIEW_CONNECTIONS,
|
||||
AI_REVIEW_REQUIREMENT,
|
||||
} from "../fixtures/aiConnections";
|
||||
|
||||
const meta = {
|
||||
title: "AI Connections/Review",
|
||||
component: AiConnectionsReview,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [(Story, context) => (
|
||||
<AiReviewFrame {...aiReviewContext(context.id, context.args)}><Story /></AiReviewFrame>
|
||||
)],
|
||||
} satisfies Meta<typeof AiConnectionsReview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const groups = [
|
||||
[
|
||||
"Connections",
|
||||
[
|
||||
["Existing Connectors page with AI providers", "provider-catalog"],
|
||||
["Connection list", "connections"],
|
||||
["Add account through existing Connectors", "connect-from-existing-catalog"],
|
||||
["Provider and identity matrix", "identity-matrix"],
|
||||
["Manage account", "management"],
|
||||
["Change personal default", "change-personal-default"],
|
||||
["Unavailable default", "revoked-default"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Choose an account",
|
||||
[
|
||||
["Responsible user", "responsible-user"],
|
||||
["Company shared", "shared-selected"],
|
||||
["Human access denied", "shared-audience-denied"],
|
||||
["Another responsible user", "another-user-missing"],
|
||||
["Incompatible selection", "incompatible-selection"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Authentication",
|
||||
[
|
||||
["Claude subscription", "claude-subscription"],
|
||||
["ChatGPT subscription", "chat-gpt-subscription"],
|
||||
["Grok subscription", "grok-subscription"],
|
||||
["API key", "open-router-api-key"],
|
||||
["Invalid credentials and retry", "api-key-retry"],
|
||||
["Expired sign-in", "expired-attempt"],
|
||||
["Cancel and restore focus", "cancel-and-restore-focus"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Complete flows",
|
||||
[
|
||||
["First onboarding", "first-onboarding"],
|
||||
["Onboarding reuse", "onboarding-reuse"],
|
||||
["New-agent reuse", "new-agent-reuse"],
|
||||
["Inline task connection", "inline-task-connection"],
|
||||
["Inline task reuse", "inline-task-reuse"],
|
||||
["Settings change", "settings-change"],
|
||||
["Legacy adoption", "legacy-adoption"],
|
||||
],
|
||||
],
|
||||
] as const;
|
||||
|
||||
export const ReviewIndex: Story = {
|
||||
render: () => (
|
||||
<main className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
||||
<h1 className="text-xl font-semibold">AI Connections · Review index</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Milestone 1: shared UI, simulated accounts, no live authentication.
|
||||
Start with the existing Connectors page, then account details, provider
|
||||
sign-in and agent connection selection. Use the Storybook toolbar for light/dark themes and narrow
|
||||
viewports.
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Personal defaults are per company, provider, and sign-in method.
|
||||
Connection selection never changes harness or model. Unavailable
|
||||
accounts block without fallback.
|
||||
</p>
|
||||
{groups.map(([title, links]) => (
|
||||
<section key={title} className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
{links.map(([label, id]) => (
|
||||
<a
|
||||
className="text-sm underline underline-offset-2"
|
||||
key={id}
|
||||
href={`/?path=/story/ai-connections-review--${id}`}
|
||||
target="_top"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Additional stories cover read-only, loading, denied access,
|
||||
reauthorization, revocation, mobile, and unsupported environments.
|
||||
Runtime enforcement and data migration follow UI review.
|
||||
</p>
|
||||
</main>
|
||||
),
|
||||
};
|
||||
export const ProviderCatalog: Story = {
|
||||
args: { host: "connections", initialStage: "providers" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByRole("button", { name: "Add account Anthropic" })).toBeVisible();
|
||||
await expect(canvas.getByRole("button", { name: "Connect GitHub" })).toBeVisible();
|
||||
await expect(canvas.getByRole("button", { name: "Connect Gmail" })).toBeVisible();
|
||||
await userEvent.type(canvas.getByPlaceholderText("Search connectors…"), "Claude");
|
||||
await expect(canvas.queryByRole("button", { name: "Connect Gmail" })).not.toBeInTheDocument();
|
||||
await userEvent.clear(canvas.getByPlaceholderText("Search connectors…"));
|
||||
},
|
||||
};
|
||||
export const ConnectFromExistingCatalog: Story = {
|
||||
args: { host: "connections" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Add account Anthropic" }));
|
||||
await userEvent.click(await canvas.findByRole("radio", { name: "Any agent" }));
|
||||
await userEvent.click(canvas.getByRole("button", { name: /^(Save and continue|Continue)$/ }));
|
||||
const name = await canvas.findByLabelText("Connection name");
|
||||
await userEvent.clear(name); await userEvent.type(name, "My additional Claude account");
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Sign in" }));
|
||||
await userEvent.type(await canvas.findByLabelText("Authorization code"), "fixture-code");
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Submit code" }));
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Use connection" }));
|
||||
await expect(await canvas.findByLabelText("AI account settings")).toBeVisible();
|
||||
await expect(canvas.getByRole("heading", { name: "My additional Claude account" })).toBeVisible();
|
||||
await userEvent.click(canvas.getByRole("link", { name: "Connectors" }));
|
||||
await expect(await canvas.findByRole("button", { name: "Open My additional Claude account permissions" })).toBeVisible();
|
||||
await expect(canvas.getByRole("button", { name: "Open My Claude subscription permissions" })).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const Connections: Story = { args: { host: "connections" } };
|
||||
export const IdentityMatrix: Story = {
|
||||
render: () => <AiConnectorPages />,
|
||||
};
|
||||
export const ResponsibleUser: Story = {};
|
||||
export const SharedSelected: Story = {
|
||||
args: {
|
||||
initialBinding: {
|
||||
...AI_REVIEW_BINDING,
|
||||
mode: "shared",
|
||||
connectionId: "claude-shared",
|
||||
grantId: "grant-shared",
|
||||
},
|
||||
},
|
||||
};
|
||||
export const LegacyPersonalSelectionBlocked: Story = {
|
||||
args: {
|
||||
initialBinding: {
|
||||
...AI_REVIEW_BINDING,
|
||||
mode: "delegated",
|
||||
connectionId: "claude-sam",
|
||||
grantId: "grant-sam",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole("status")).toHaveTextContent("This credential is not shared with you");
|
||||
await expect(canvas.queryByRole("button", { name: /Sam’s Claude/ })).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const NoAccounts: Story = { args: { initialConnections: [] } };
|
||||
export const AnotherUserMissing: Story = {
|
||||
args: { currentUserId: "sam", host: "task" },
|
||||
};
|
||||
export const IncompatibleSelection: Story = {
|
||||
args: {
|
||||
initialBinding: {
|
||||
mode: "shared",
|
||||
provider: "openai",
|
||||
method: "api_key",
|
||||
connectionId: "openai-api",
|
||||
grantId: "grant-openai-api",
|
||||
},
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { args: { loading: true } };
|
||||
export const LoadFailed: Story = {
|
||||
args: { error: "Could not load AI connections. Try again." },
|
||||
};
|
||||
export const ReadOnly: Story = { args: { readOnly: true } };
|
||||
export const SharedAudienceDenied: Story = {
|
||||
args: {
|
||||
initialConnections: AI_REVIEW_CONNECTIONS.map((row) =>
|
||||
row.id === "claude-shared"
|
||||
? {
|
||||
...row,
|
||||
unavailableReason:
|
||||
"The responsible user is not permitted to use this company account.",
|
||||
}
|
||||
: row,
|
||||
),
|
||||
},
|
||||
};
|
||||
export const RevokedDefault: Story = {
|
||||
args: {
|
||||
initialConnections: AI_REVIEW_CONNECTIONS.map((row) =>
|
||||
row.id === "claude-dotta" ? { ...row, status: "revoked" } : row,
|
||||
),
|
||||
},
|
||||
};
|
||||
export const ChangePersonalDefault: Story = {
|
||||
args: {
|
||||
host: "connections", initialStage: "manage",
|
||||
initialConnections: [AI_REVIEW_CONNECTIONS[1], ...AI_REVIEW_CONNECTIONS.filter((row) => row.id !== AI_REVIEW_CONNECTIONS[1].id)],
|
||||
},
|
||||
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.getByLabelText("AI account settings")).toHaveTextContent("Your default");
|
||||
await expect(canvas.queryByRole("button", { name: "Make default" })).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
const authArgs = { initialStage: "auth" as const };
|
||||
const waiting = {
|
||||
phase: "waiting" as const,
|
||||
authorizationUrl: "#storybook-provider-simulator",
|
||||
code: "DEMO-CODE",
|
||||
};
|
||||
export const ClaudeSubscription: Story = {
|
||||
args: { ...authArgs, initialAuthState: waiting },
|
||||
};
|
||||
export const ChatGptSubscription: Story = {
|
||||
args: {
|
||||
...authArgs,
|
||||
requirement: { ...AI_REVIEW_REQUIREMENT, provider: "openai" },
|
||||
initialAuthState: waiting,
|
||||
},
|
||||
};
|
||||
export const GrokSubscription: Story = {
|
||||
args: {
|
||||
...authArgs,
|
||||
requirement: { ...AI_REVIEW_REQUIREMENT, provider: "xai" },
|
||||
initialAuthState: waiting,
|
||||
},
|
||||
};
|
||||
export const ClaudeApiKey: Story = {
|
||||
args: {
|
||||
...authArgs,
|
||||
requirement: { ...AI_REVIEW_REQUIREMENT, method: "api_key" },
|
||||
},
|
||||
};
|
||||
export const OpenAiApiKey: Story = {
|
||||
args: {
|
||||
...authArgs,
|
||||
requirement: {
|
||||
...AI_REVIEW_REQUIREMENT,
|
||||
provider: "openai",
|
||||
method: "api_key",
|
||||
},
|
||||
},
|
||||
};
|
||||
export const OpenRouterApiKey: Story = {
|
||||
args: {
|
||||
...authArgs,
|
||||
requirement: {
|
||||
...AI_REVIEW_REQUIREMENT,
|
||||
provider: "openrouter",
|
||||
method: "api_key",
|
||||
},
|
||||
},
|
||||
};
|
||||
export const GrokApiKey: Story = {
|
||||
args: {
|
||||
...authArgs,
|
||||
requirement: {
|
||||
...AI_REVIEW_REQUIREMENT,
|
||||
provider: "xai",
|
||||
method: "api_key",
|
||||
},
|
||||
},
|
||||
};
|
||||
export const PreparingLogin: Story = {
|
||||
args: { ...authArgs, initialAuthState: { phase: "starting" } },
|
||||
};
|
||||
export const Connected: Story = {
|
||||
args: { ...authArgs, initialAuthState: { phase: "connected" } },
|
||||
};
|
||||
export const Cancelled: Story = {
|
||||
args: { ...authArgs, initialAuthState: { phase: "cancelled" } },
|
||||
};
|
||||
export const ExpiredAttempt: Story = {
|
||||
args: {
|
||||
...authArgs,
|
||||
initialAuthState: {
|
||||
phase: "expired",
|
||||
message:
|
||||
"This sign-in attempt expired. Start again to receive a new code.",
|
||||
},
|
||||
},
|
||||
};
|
||||
export const UnsupportedEnvironment: Story = {
|
||||
args: {
|
||||
...authArgs,
|
||||
initialAuthState: {
|
||||
phase: "unsupported",
|
||||
message:
|
||||
"Subscription sign-in is unavailable in this environment. Choose an environment that supports this provider’s login.",
|
||||
},
|
||||
},
|
||||
};
|
||||
export const InvalidCredentials: Story = {
|
||||
args: {
|
||||
...authArgs,
|
||||
requirement: { ...AI_REVIEW_REQUIREMENT, method: "api_key" },
|
||||
initialAuthState: {
|
||||
phase: "error",
|
||||
message:
|
||||
"The provider rejected this API key. Check the key and try again.",
|
||||
},
|
||||
},
|
||||
};
|
||||
export const ApiKeyRetry: Story = {
|
||||
args: {
|
||||
...authArgs,
|
||||
initialConnections: [],
|
||||
requirement: {
|
||||
...AI_REVIEW_REQUIREMENT,
|
||||
provider: "openrouter",
|
||||
method: "api_key",
|
||||
},
|
||||
failFirstAttempt: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.type(
|
||||
canvas.getByLabelText("API key"),
|
||||
"storybook-not-a-key",
|
||||
);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Connect" }));
|
||||
await expect(canvas.getByRole("alert")).toHaveTextContent(
|
||||
"could not verify",
|
||||
);
|
||||
await expect(canvas.getByLabelText("API key")).toHaveValue("");
|
||||
await userEvent.type(
|
||||
canvas.getByLabelText("API key"),
|
||||
"storybook-retry-not-a-key",
|
||||
);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Connect" }));
|
||||
await expect(canvas.getByRole("status")).toHaveTextContent("Connected.");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Use connection" }),
|
||||
);
|
||||
await expect(canvas.getByText("For you: My OpenRouter API")).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const FirstOnboarding: Story = {
|
||||
args: { host: "onboarding", initialConnections: [], initialStage: "auth" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Sign in" }));
|
||||
await userEvent.type(
|
||||
canvas.getByLabelText("Authorization code"),
|
||||
"storybook-code",
|
||||
);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Submit code" }));
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Use connection" }),
|
||||
);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Continue" }));
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: "Create another agent using existing connections",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
canvas.getByText("For you: My Claude subscription"),
|
||||
).toBeVisible();
|
||||
await expect(canvas.getByTestId("ai-harness")).toHaveTextContent(
|
||||
"Claude Code",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const OnboardingReuse: Story = { args: { host: "onboarding" } };
|
||||
export const NewAgentReuse: Story = { args: { host: "new_agent" } };
|
||||
export const InlineTaskConnection: Story = {
|
||||
render: () => <AiTaskConnectionReview />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement); const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Connect" }));
|
||||
const dialog = within(await body.findByRole("dialog"));
|
||||
await userEvent.click(await dialog.findByRole("button", { name: /^(Save and continue|Continue)$/ }));
|
||||
await userEvent.click(await dialog.findByRole("button", { name: "Sign in" }));
|
||||
await userEvent.type(dialog.getByLabelText("Authorization code"), "fixture-task-code");
|
||||
await userEvent.click(dialog.getByRole("button", { name: "Submit code" }));
|
||||
await userEvent.click(dialog.getByRole("button", { name: "Use connection" }));
|
||||
await expect(await canvas.findByText("Claude connected")).toBeVisible();
|
||||
await expect(canvas.getByTestId("connection-intent-focus-target")).toHaveFocus();
|
||||
},
|
||||
};
|
||||
export const InlineTaskReuse: Story = {
|
||||
render: () => <AiTaskConnectionReview reuse />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement); const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Connect / Use existing" }));
|
||||
const dialog = within(await body.findByRole("dialog"));
|
||||
await userEvent.click(await dialog.findByRole("button", { name: "My Claude subscription" }));
|
||||
await expect(await canvas.findByText("Claude connected")).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const SettingsChange: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Engineering Claude" }),
|
||||
);
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Engineering Claude" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(canvas.getByTestId("ai-harness")).toHaveTextContent(
|
||||
"Claude Code",
|
||||
);
|
||||
await expect(canvas.getByTestId("ai-model")).toHaveTextContent(
|
||||
"Configured Claude model",
|
||||
);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Save connection" }),
|
||||
);
|
||||
await expect(canvas.getByRole("status")).toHaveTextContent(
|
||||
"Connection selected for Nova",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const CancelAndRestoreFocus: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Connect another account" }),
|
||||
);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Sign in" }));
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Connect another account" }),
|
||||
).toHaveFocus();
|
||||
await expect(
|
||||
canvas.getByText("For you: My Claude subscription"),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const Management: Story = {
|
||||
args: { host: "connections", initialStage: "manage" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByRole("heading", { name: "Which humans can use this credential?" })).toBeVisible();
|
||||
await expect(canvas.getByRole("heading", { name: "Which agents can use this connection?" })).toBeVisible();
|
||||
await expect(canvas.queryByText("Authorized use for other users’ tasks")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const ManagementReadOnly: Story = {
|
||||
args: { host: "connections", initialStage: "manage", readOnly: true },
|
||||
};
|
||||
export const ReconnectExisting: Story = {
|
||||
args: {
|
||||
host: "connections",
|
||||
initialStage: "manage",
|
||||
initialConnections: AI_REVIEW_CONNECTIONS.map((connection) =>
|
||||
connection.id === "claude-dotta"
|
||||
? { ...connection, status: "expired" }
|
||||
: connection,
|
||||
),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Reconnect" }));
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Sign in" }));
|
||||
await userEvent.type(await canvas.findByLabelText("Authorization code"), "fixture-reconnect");
|
||||
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("Your default");
|
||||
},
|
||||
};
|
||||
export const RevokeConnection: Story = {
|
||||
args: { host: "connections", initialStage: "manage" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement); const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Revoke identity" }));
|
||||
const dialog = within(await body.findByRole("alertdialog"));
|
||||
await expect(dialog.getByText(/Existing runs may retain credentials/)).toBeVisible();
|
||||
await userEvent.click(dialog.getByRole("button", { name: "Revoke identity" }));
|
||||
await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Default unavailable");
|
||||
},
|
||||
};
|
||||
export const LegacyAdoption: Story = {
|
||||
args: { initialStage: "legacy" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Choose a managed connection" }),
|
||||
);
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Adopt connection" }),
|
||||
).toBeDisabled();
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Test selected connection" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Adopt connection" }),
|
||||
);
|
||||
await expect(canvas.getByRole("status")).toHaveTextContent(
|
||||
"Managed connection adopted.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const MobilePicker: Story = {
|
||||
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||
};
|
||||
export const MobileAuthentication: Story = {
|
||||
args: { ...authArgs, initialAuthState: waiting },
|
||||
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||
};
|
||||
|
|
@ -31,7 +31,7 @@ const connection = {
|
|||
createdByAgentId: null, createdByUserId: "user-board", createdAt: new Date("2026-09-07"), updatedAt: new Date("2026-09-07"),
|
||||
} satisfies ToolConnection;
|
||||
|
||||
type Scenario = { checking?: boolean; count?: number; loading?: boolean; loadError?: boolean; completeError?: boolean; submitting?: boolean; denied?: boolean };
|
||||
type Scenario = { ai?: boolean; ownerOnly?: boolean; checking?: boolean; count?: number; loading?: boolean; loadError?: boolean; completeError?: boolean; submitting?: boolean; denied?: boolean };
|
||||
const meta: Meta = {
|
||||
title: "Connections/In-task connections",
|
||||
parameters: { layout: "padded" },
|
||||
|
|
@ -45,13 +45,16 @@ const meta: Meta = {
|
|||
channel.on("unhandledErrorsWhilePlaying", reportPlayError);
|
||||
const original = window.fetch;
|
||||
const scenario = (parameters.connectionScenario ?? {}) as Scenario;
|
||||
let current = structuredClone(pending);
|
||||
let current = structuredClone(scenario.ai ? aiPending : pending);
|
||||
window.fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin);
|
||||
if (url.pathname.endsWith("/tools/gallery")) return Response.json({
|
||||
apps: CONNECTABLE_APP_DEFINITIONS.filter((app) => ["notion", "github", "posthog", "zapier"].includes(app.slug)),
|
||||
capabilities: { canCreateOrganizationGrant: true, canSetCompanyInstall: true },
|
||||
});
|
||||
if (scenario.ai && url.pathname.endsWith("/ai-connections") && init?.method === "POST") return scenario.completeError
|
||||
? Response.json({ error: "This key could not be verified. Check it and try again." }, { status: 422 })
|
||||
: Response.json({ connectionId: aiAccount.id, grantId: aiAccount.grantId });
|
||||
if (url.pathname.endsWith("/agents")) return Response.json([{ id: pending.payload.requestingAgentId, companyId: pending.companyId, name: pending.payload.requestingAgentName, status: "active", adapterType: "paperclip_runner", role: "researcher" }]);
|
||||
if (url.pathname.startsWith("/api/connection-intents/")) {
|
||||
if (url.pathname.endsWith("setup-options")) {
|
||||
|
|
@ -59,13 +62,14 @@ const meta: Meta = {
|
|||
if (scenario.loadError) return Response.json({ error: "Connection options are temporarily unavailable. Try again." }, { status: 503 });
|
||||
return Response.json({ version: 1, interaction: current, requestedAgentId: pending.payload.requestingAgentId,
|
||||
service: { service: "notion", name: "Notion", state: "available", methods: [] },
|
||||
...(scenario.ai ? { aiConnection: { provider: "openrouter", method: "api_key", mode: "responsible_user" }, aiRepair: { connection: aiAccount, canReconnect: !scenario.ownerOnly } } : {}),
|
||||
existingConnections: Array.from({ length: scenario.count ?? 0 }, (_, i) => ({ ...connection, id: `${connection.id.slice(0, -1)}${i}`, name: i ? "Team Notion workspace" : connection.name })),
|
||||
});
|
||||
}
|
||||
if (scenario.submitting) return new Promise<Response>(() => {});
|
||||
if (scenario.completeError || scenario.denied) return Response.json({ error: scenario.denied ? "You no longer have permission to share this connection." : "Connection has no permitted tools. Review action permissions and try again." }, { status: scenario.denied ? 403 : 409 });
|
||||
if (url.pathname.endsWith("decline")) current = { ...declined, id: pending.id };
|
||||
else if (url.pathname.endsWith("complete")) current = { ...connected, id: pending.id };
|
||||
else if (url.pathname.endsWith("complete")) current = { ...connected, id: pending.id, payload: current.payload };
|
||||
else if (url.pathname.endsWith("phase")) current = { ...current, payload: { ...current.payload, phase: "needs_retry" } };
|
||||
return Response.json(current);
|
||||
}
|
||||
|
|
@ -238,3 +242,49 @@ export const SetupFailureRetry: Story = { ...SetupFailure, play: async (context)
|
|||
await userEvent.click(within(document.body).getByRole("button", { name: /Check link/i }));
|
||||
await expect(await within(document.body).findByText(/Fixture connection could not be verified/)).toBeVisible();
|
||||
}};
|
||||
|
||||
|
||||
// Storybook supplies only deterministic data/actions. The card and credential
|
||||
// form below are the production components used inside the task.
|
||||
const openrouter = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "openrouter")!;
|
||||
const aiPending: ConnectionIntentInteraction = { ...pending, payload: { ...pending.payload, purpose: "ai", serviceName: "OpenRouter", serviceSlug: "openrouter", serviceLogoUrl: openrouter.branding.logoUrl ?? null, serviceDarkLogoUrl: openrouter.branding.darkLogoUrl ?? null } };
|
||||
const aiAccount = { id: connection.id, grantId: "storybook-grant", companyId: pending.companyId, provider: "openrouter", method: "api_key", name: "My OpenRouter account", ownership: "personal", ownerName: "Alex", isDefault: true, status: "revoked" };
|
||||
const aiRepair: Story = {
|
||||
parameters: { connectionScenario: { ai: true } },
|
||||
render: () => <Host><Card interaction={aiPending} /></Host>,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Fix connection" }));
|
||||
await expect(await canvas.findByLabelText("Connection name")).toBeDisabled();
|
||||
await expect(within(document.body).queryByRole("dialog")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const AiInlineRepair = aiRepair;
|
||||
export const AiInlineRepairNarrow: Story = { ...aiRepair, globals: { viewport: { value: "mobile1", isRotated: false } } };
|
||||
export const AiRepairCancel: Story = { ...aiRepair, play: async context => {
|
||||
await aiRepair.play!(context);
|
||||
const canvas = within(context.canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
|
||||
await expect(canvas.queryByTestId("ai-connection-inline-repair")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(canvas.getByTestId("connection-intent-focus-target")).toHaveFocus());
|
||||
}};
|
||||
export const AiRepairComplete: Story = { ...aiRepair, play: async context => {
|
||||
await aiRepair.play!(context);
|
||||
const canvas = within(context.canvasElement);
|
||||
await userEvent.type(canvas.getByPlaceholderText("Enter API key here"), "storybook-placeholder");
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Connect" }));
|
||||
await expect(await canvas.findByText("OpenRouter connected")).toBeVisible();
|
||||
}};
|
||||
export const AiRepairInvalidKey: Story = { ...aiRepair, parameters: { connectionScenario: { ai: true, completeError: true } }, play: async context => {
|
||||
await aiRepair.play!(context);
|
||||
const canvas = within(context.canvasElement);
|
||||
await userEvent.type(canvas.getByPlaceholderText("Enter API key here"), "storybook-placeholder");
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Connect" }));
|
||||
await expect(await canvas.findByRole("alert")).toHaveTextContent("This key could not be verified. Check it and try again.");
|
||||
}};
|
||||
export const AiRepairOwnerRequired: Story = { ...aiRepair, parameters: { connectionScenario: { ai: true, ownerOnly: true } }, play: async ({canvasElement}) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Fix connection" }));
|
||||
await expect(await canvas.findByText(/Alex must reconnect/)).toBeVisible();
|
||||
await expect(canvas.queryByPlaceholderText("Enter API key here")).not.toBeInTheDocument();
|
||||
}};
|
||||
|
|
|
|||
Loading…
Reference in New Issue