Files
PaperClipAI/ui/src/components/onboarding/SavedProviderKeySelect.tsx
T
DottaandPaperclip 8d6232e7b0 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>
2026-09-12 16:51:26 -05:00

134 lines
4.6 KiB
TypeScript

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";
import { secretsApi } from "@/api/secrets";
import { queryKeys } from "@/lib/queryKeys";
import {
savedProviderKeys,
savedManagedProviderAccounts,
savedCodexSubscriptions,
type SavedProviderKey,
} from "@/lib/saved-provider-credentials";
export function useSavedProviderKeys(
companyId: string | null,
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!),
enabled: Boolean(companyId) && enabled,
retry: false,
});
const organization = useQuery({
queryKey: queryKeys.secrets.list(companyId ?? ""),
queryFn: () => secretsApi.list(companyId!),
enabled: Boolean(companyId) && enabled,
retry: false,
});
const storedLogin = useQuery({
// Disabled queries still return cached data. Keep other providers away
// from the shared Claude login cache.
queryKey: ["claude-oauth-token-status", envKey === "ANTHROPIC_API_KEY" ? companyId : null],
queryFn: async () => {
try {
return await agentsApi.getClaudeOAuthTokenStatus(companyId!);
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
},
enabled: Boolean(companyId) && enabled && envKey === "ANTHROPIC_API_KEY",
retry: false,
});
return {
storedLogin,
options: [...managedAccounts.filter(account => account.aiConnection?.method === "api_key"), ...savedProviderKeys(
companyId ?? "",
envKey,
personal.data ?? [],
organization.data ?? [],
)],
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 || managed.isLoading,
error: personal.isError || organization.isError || managed.isError,
};
}
export function SavedProviderKeySelect({
options,
value,
onChange,
loading,
error,
disabled,
kind = "api",
}: {
options: SavedProviderKey[];
value: string;
onChange: (id: string) => void;
loading: boolean;
error: boolean;
disabled?: boolean;
kind?: "api" | "subscription";
}) {
return (
<div className="space-y-2">
{options.length > 0 && (
<label className="block space-y-2 text-sm">
<span>{kind === "api" ? "API key" : "Subscription"}</span>
<select
aria-label={kind === "api" ? "Saved API key" : "Saved subscription"}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
<option value="">
{kind === "api"
? "Enter a new API key"
: "Sign in to another account"}
</option>
</select>
</label>
)}
{loading && (
<p role="status" className="text-sm text-muted-foreground">
Checking saved API keys…
</p>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
Some saved keys could not be loaded. You can still enter a new key.
</p>
)}
{value && (
<p className="text-sm text-muted-foreground">
Reuse this saved {kind === "api" ? "key" : "subscription"} for this
agent.
</p>
)}
</div>
);
}