fix(connections): reuse one-time cloud enrollment (#12891)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Managed connections let agents use provider credentials without exposing those credentials to the control plane UI > - A self-hosted instance must first establish a trusted credential destination with Paperclip Cloud > - The GitHub connection flow repeated that trust decision before provider consent > - The local setup route also lost step 2 after enrollment and could display the PAT identity defaults before enrollment > - This pull request makes enrollment a one-time instance decision and sends later provider starts directly to provider consent > - The benefit is a shorter flow with one clear Paperclip approval and no required service restart ## Linked Issues or Issue Description Refs #12843. Companion Cloud change: [paperclipai/paperclip-cloud#391](https://github.com/paperclipai/paperclip-cloud/pull/391). ## What Changed - Made `stage=setup` authoritative during initial route hydration and enrollment return. - Added a contained one-time enrollment screen with provider-specific copy. - Accepted a provider `authorizationUrl` from Paperclip Cloud only when it matches the exact GitHub or Google OAuth endpoint. - Preferred the direct provider URL while retaining the legacy confirmation URL fallback. - Preserved the company-bound identity and agent-access draft across the full-page enrollment callback, including cold company-context hydration. - Kept GitHub defaulted to “My GitHub account” and “Any agent,” including before Cloud advertises the managed method. - Updated GitHub identity and agent-access copy for responsible-person and dedicated-agent behavior. - Labeled the provider action “Continue to GitHub.” - Added parser, routing, cold-hydration, access-restoration, visibility, fallback, defaults, and copy tests. ## Verification - `pnpm exec vitest run server/src/services/paperclip-cloud-connector.test.ts ui/src/pages/apps/AppsConnect.test.tsx` (114 tests passed) - `pnpm check:token-gates` - `pnpm -r typecheck` - `pnpm build` - Live browser proof used a new data directory on `127.0.0.1:3117` and the exact Cloud PR revision on staging. - The fresh flow selected “My GitHub account” and “Any agent,” showed one enrollment approval, returned to local step 2, and connected GitHub without a second Paperclip confirmation or login. - The connected screen showed one selected repository, a long-lived token, installation metadata, a successful access refresh, and healthy webhook delivery. - Gmail on the same instance went directly to Google consent without another Paperclip approval. - Restarting the same data directory preserved enrollment. A second new data directory required exactly one new approval. - A final fresh-data-dir rerun selected a dedicated GitHub identity for Ada before enrollment, approved the instance once, returned to step 2, retained Ada after a Back check, connected directly through GitHub, and finished with “Used only by Ada,” one selected repository, and a long-lived token. - Port 3100 remained untouched throughout the proof. ## Risks - The new Cloud field is additive and restricted to the exact GitHub and Google OAuth origins and paths, with no embedded credentials or URL fragment. - An older Cloud response still works through `confirmationUrl`. - A self-hosted instance still requires one signed Cloud enrollment. Managed Cloud instances do not render the enrollment screen. - Provider authentication and consent remain mandatory after instance enrollment. - No schema migration is included in this pull request. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, `gpt-5.6-sol`, extended reasoning, tool use, code execution, browser control, and multi-file repository editing. The context window size was not provided. ## 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
This commit is contained in:
parent
64d8929ce9
commit
5da6499860
|
|
@ -93,6 +93,78 @@ describe("Paperclip Cloud connector", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("prefers a validated HTTPS provider URL over the legacy confirmation URL", async () => {
|
||||
const keys = config();
|
||||
const connector = createPaperclipCloudConnector({
|
||||
config: keys.config,
|
||||
request: vi.fn(async () => Response.json({
|
||||
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
|
||||
authorizationUrl: "https://github.com/login/oauth/authorize?client_id=client&state=broker-state",
|
||||
expiresAt: "2099-08-21T20:00:00.000Z",
|
||||
})) as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(connector.startAuthorization({
|
||||
subject,
|
||||
companyId,
|
||||
profile: "github.code",
|
||||
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
|
||||
returnState: "state-direct",
|
||||
})).resolves.toMatchObject({
|
||||
authorizationUrl: "https://github.com/login/oauth/authorize?client_id=client&state=broker-state",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the fixed Google authorization endpoint for Google profiles", async () => {
|
||||
const keys = config();
|
||||
const connector = createPaperclipCloudConnector({
|
||||
config: keys.config,
|
||||
request: vi.fn(async () => Response.json({
|
||||
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
|
||||
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?client_id=client&state=broker-state",
|
||||
expiresAt: "2099-08-21T20:00:00.000Z",
|
||||
})) as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(connector.startAuthorization({
|
||||
subject,
|
||||
companyId,
|
||||
profile: "gmail.draft",
|
||||
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
|
||||
returnState: "state-direct-google",
|
||||
})).resolves.toMatchObject({
|
||||
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?client_id=client&state=broker-state",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["non-string", { href: "https://github.com/login/oauth/authorize" }],
|
||||
["plaintext HTTP", "http://github.com/login/oauth/authorize"],
|
||||
["embedded credentials", "https://user:password@github.com/login/oauth/authorize"],
|
||||
["fragment", "https://github.com/login/oauth/authorize#unexpected"],
|
||||
["unapproved HTTPS origin", "https://attacker.example.test/login/oauth/authorize"],
|
||||
["unapproved provider path", "https://github.com/session/authorize"],
|
||||
["not a URL", "not-a-url"],
|
||||
])("rejects a malformed direct provider URL: %s", async (_label, authorizationUrl) => {
|
||||
const keys = config();
|
||||
const connector = createPaperclipCloudConnector({
|
||||
config: keys.config,
|
||||
request: vi.fn(async () => Response.json({
|
||||
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
|
||||
authorizationUrl,
|
||||
expiresAt: "2099-08-21T20:00:00.000Z",
|
||||
})) as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(connector.startAuthorization({
|
||||
subject,
|
||||
companyId,
|
||||
profile: "github.code",
|
||||
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
|
||||
returnState: "state-malformed-direct",
|
||||
})).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" });
|
||||
});
|
||||
|
||||
it("binds active GitHub installations to proof from the current user token", async () => {
|
||||
const keys = config();
|
||||
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ type SealedEnvelope = {
|
|||
|
||||
type ConnectorResponse = {
|
||||
confirmationUrl?: unknown;
|
||||
authorizationUrl?: unknown;
|
||||
handoff?: unknown;
|
||||
expiresAt?: unknown;
|
||||
scopes?: unknown;
|
||||
|
|
@ -348,14 +349,45 @@ export function createPaperclipCloudConnector(input: {
|
|||
if (typeof response.confirmationUrl !== "string" || typeof response.expiresAt !== "string") {
|
||||
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid session", "CONNECTOR_BAD_RESPONSE");
|
||||
}
|
||||
const confirmationUrl = new URL(response.confirmationUrl);
|
||||
const expectedBroker = new URL(config.baseUrl);
|
||||
if (confirmationUrl.origin !== expectedBroker.origin || confirmationUrl.pathname !== "/connections/confirm") {
|
||||
let confirmationUrl: URL;
|
||||
try {
|
||||
confirmationUrl = new URL(response.confirmationUrl);
|
||||
} catch {
|
||||
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
|
||||
}
|
||||
const expectedBroker = new URL(config.baseUrl);
|
||||
if (
|
||||
confirmationUrl.origin !== expectedBroker.origin
|
||||
|| confirmationUrl.pathname !== "/connections/confirm"
|
||||
|| confirmationUrl.username
|
||||
|| confirmationUrl.password
|
||||
|| confirmationUrl.hash
|
||||
) {
|
||||
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
|
||||
}
|
||||
let authorizationUrl: URL | undefined;
|
||||
if (response.authorizationUrl !== undefined) {
|
||||
if (typeof response.authorizationUrl !== "string") {
|
||||
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
|
||||
}
|
||||
try {
|
||||
authorizationUrl = new URL(response.authorizationUrl);
|
||||
} catch {
|
||||
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
|
||||
}
|
||||
if (
|
||||
authorizationUrl.protocol !== "https:"
|
||||
|| authorizationUrl.username
|
||||
|| authorizationUrl.password
|
||||
|| authorizationUrl.hash
|
||||
|| !isExpectedProviderAuthorizationUrl(profile, authorizationUrl)
|
||||
) {
|
||||
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
|
||||
}
|
||||
}
|
||||
const handoff = parseCloudHandoff(response.handoff);
|
||||
return {
|
||||
authorizationUrl: confirmationUrl.toString(),
|
||||
authorizationUrl: authorizationUrl?.toString() ?? confirmationUrl.toString(),
|
||||
expiresAt: response.expiresAt,
|
||||
...(handoff ? { handoff } : {}),
|
||||
};
|
||||
|
|
@ -647,6 +679,16 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId):
|
|||
return { provider: "google", scopes: GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes };
|
||||
}
|
||||
|
||||
function isExpectedProviderAuthorizationUrl(
|
||||
profile: PaperclipCloudConnectorProfileId,
|
||||
url: URL,
|
||||
): boolean {
|
||||
if (isGitHubConnectorProfileId(profile)) {
|
||||
return url.origin === "https://github.com" && url.pathname === "/login/oauth/authorize";
|
||||
}
|
||||
return url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
|
||||
}
|
||||
|
||||
function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId {
|
||||
return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,62 @@ import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayloa
|
|||
type Step = "gallery" | "access" | "key" | "success";
|
||||
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
|
||||
|
||||
type EnrollmentAccessState = {
|
||||
companyId: string;
|
||||
grantKind: ConnectionGrantKind;
|
||||
installChoice: "specific" | "all";
|
||||
agentIds: string[];
|
||||
};
|
||||
|
||||
function enrollmentAccessStorageKey(appKey: string): string {
|
||||
return `paperclip.connector-enrollment-access:${appKey}`;
|
||||
}
|
||||
|
||||
function validEnrollmentAccessState(value: unknown): value is EnrollmentAccessState {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (typeof candidate.companyId !== "string" || !candidate.companyId.trim()) return false;
|
||||
if (!(candidate.grantKind === "user" || candidate.grantKind === "agent" || candidate.grantKind === "organization")) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.installChoice !== "specific" && candidate.installChoice !== "all") return false;
|
||||
if (!Array.isArray(candidate.agentIds) || candidate.agentIds.some((id) => typeof id !== "string" || !id.trim())) {
|
||||
return false;
|
||||
}
|
||||
const agentIds = new Set(candidate.agentIds);
|
||||
if (agentIds.size !== candidate.agentIds.length) return false;
|
||||
if (candidate.grantKind === "agent") {
|
||||
return candidate.installChoice === "specific" && agentIds.size === 1;
|
||||
}
|
||||
return candidate.installChoice === "all" ? agentIds.size === 0 : agentIds.size > 0;
|
||||
}
|
||||
|
||||
function saveEnrollmentAccessState(
|
||||
companyId: string,
|
||||
appKey: string,
|
||||
state: Omit<EnrollmentAccessState, "companyId">,
|
||||
): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(enrollmentAccessStorageKey(appKey), JSON.stringify({ ...state, companyId }));
|
||||
} catch {
|
||||
// Browser storage can be unavailable under restrictive privacy settings.
|
||||
// The callback will safely use the provider's defaults in that case.
|
||||
}
|
||||
}
|
||||
|
||||
function consumeEnrollmentAccessState(appKey: string): EnrollmentAccessState | null {
|
||||
const key = enrollmentAccessStorageKey(appKey);
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
window.sessionStorage.removeItem(key);
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return validEnrollmentAccessState(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function githubRecoveryUrl(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
|
|
@ -124,6 +180,19 @@ const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
|
|||
success: "complete",
|
||||
};
|
||||
|
||||
export function requestedConnectionInitialStep(input: {
|
||||
requestedAppKey: string | undefined;
|
||||
routeStage: string | null;
|
||||
resumeConnectionId: string | null;
|
||||
hasPrefilledLink: boolean;
|
||||
zapierSource: boolean;
|
||||
}): Step {
|
||||
if (input.requestedAppKey) {
|
||||
return input.resumeConnectionId || input.routeStage === "setup" ? "key" : "access";
|
||||
}
|
||||
return input.hasPrefilledLink || input.zapierSource ? "access" : "gallery";
|
||||
}
|
||||
|
||||
export function requestedConnectionEntry(input: {
|
||||
requestedAppKey: string;
|
||||
galleryApps: readonly AppDefinition[];
|
||||
|
|
@ -302,6 +371,17 @@ function recommendedSetupConnectionMethod(
|
|||
: null;
|
||||
}
|
||||
|
||||
function recommendedManagedConnectorMethod(
|
||||
entry: AppDefinition | null | undefined,
|
||||
): ConnectionMethodDef | null {
|
||||
return recommendedSetupConnectionMethod(
|
||||
(entry?.methods ?? []).filter((candidate) =>
|
||||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||||
|| candidate.oauthStrategy === "paperclip_id_connector",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function canUseAutomaticOAuthFastPath(entry: AppDefinition | null | undefined): boolean {
|
||||
if (!entry) return false;
|
||||
const methods = getAvailableConnectionMethods(entry);
|
||||
|
|
@ -440,6 +520,7 @@ export function ConnectionSetupFlow({
|
|||
const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined;
|
||||
const sourceSlug = searchParams.get("source")?.trim() || null;
|
||||
const createNewConnection = searchParams.get("new") === "1";
|
||||
const routeStage = searchParams.get("stage")?.trim() || null;
|
||||
const resumeConnectionId = searchParams.get("resume")?.trim() || null;
|
||||
const oauthCallbackOutcome = searchParams.get("oauth");
|
||||
const oauthCallbackCode = searchParams.get("code");
|
||||
|
|
@ -464,6 +545,13 @@ export function ConnectionSetupFlow({
|
|||
const zapierSource = (serviceSlug ?? sourceSlug ?? appKey) === "zapier";
|
||||
const requestedAppKey = zapierSource ? undefined : routeAppKey;
|
||||
const byo = host === "page" && (byoOnly || searchParams.get("byo") === "1");
|
||||
const [restoredEnrollmentAccess] = useState<EnrollmentAccessState | null>(() =>
|
||||
host === "page"
|
||||
&& searchParams.get("cloud_connector") === "enrolled"
|
||||
&& requestedAppKey
|
||||
? consumeEnrollmentAccessState(requestedAppKey)
|
||||
: null,
|
||||
);
|
||||
|
||||
// Prefill arrives from the app page for reconnects; read once so later
|
||||
// wizard navigation doesn't fight the URL.
|
||||
|
|
@ -476,11 +564,13 @@ export function ConnectionSetupFlow({
|
|||
};
|
||||
});
|
||||
|
||||
const [step, setStep] = useState<Step>(
|
||||
requestedAppKey
|
||||
? resumeConnectionId ? "key" : "access"
|
||||
: prefill.link || zapierSource ? "access" : "gallery",
|
||||
);
|
||||
const [step, setStep] = useState<Step>(() => requestedConnectionInitialStep({
|
||||
requestedAppKey,
|
||||
routeStage,
|
||||
resumeConnectionId,
|
||||
hasPrefilledLink: Boolean(prefill.link),
|
||||
zapierSource,
|
||||
}));
|
||||
const [entry, setEntry] = useState<AppDefinition | null>(null);
|
||||
const [galleryName, setGalleryName] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState(prefill.link);
|
||||
|
|
@ -509,7 +599,7 @@ export function ConnectionSetupFlow({
|
|||
const [access, setAccess] = useState<"all" | "specific">("all");
|
||||
const [agentIds, setAgentIds] = useState<Set<string>>(new Set());
|
||||
const [installAgentIds, setInstallAgentIds] = useState<Set<string>>(
|
||||
() => new Set(requestedAgentId ? [requestedAgentId] : []),
|
||||
() => new Set(restoredEnrollmentAccess?.agentIds ?? (requestedAgentId ? [requestedAgentId] : [])),
|
||||
);
|
||||
/**
|
||||
* Access-step selections (PAP-17835). These are chosen before the credential
|
||||
|
|
@ -517,10 +607,10 @@ export function ConnectionSetupFlow({
|
|||
* backwards through the wizard.
|
||||
*/
|
||||
const [grantKind, setGrantKind] = useState<ConnectionGrantKind>(
|
||||
reconnectGrantKindHint ?? "organization",
|
||||
restoredEnrollmentAccess?.grantKind ?? reconnectGrantKindHint ?? "organization",
|
||||
);
|
||||
const [installChoice, setInstallChoice] = useState<"specific" | "all">(
|
||||
requestedAgentId ? "specific" : "all",
|
||||
restoredEnrollmentAccess?.installChoice ?? (requestedAgentId ? "specific" : "all"),
|
||||
);
|
||||
const resumingAfterOAuthFailure = Boolean(
|
||||
resumeConnectionId
|
||||
|
|
@ -729,6 +819,16 @@ export function ConnectionSetupFlow({
|
|||
|| candidate.oauthStrategy === "paperclip_id_connector"
|
||||
),
|
||||
);
|
||||
// Before a self-hosted instance enrolls, the server intentionally withholds
|
||||
// platform-managed methods from the advertised gallery. The setup route still
|
||||
// needs the managed method's identity model, labels, and defaults because the
|
||||
// next step is enrollment for that exact method—not the visible PAT/BYO
|
||||
// compatibility fallback.
|
||||
const preEnrollmentManagedMethod = entry
|
||||
&& requestedDefinitionUsesManagedConnector
|
||||
&& !entryAdvertisesManagedConnector
|
||||
? recommendedManagedConnectorMethod(fullRequestedDefinition)
|
||||
: null;
|
||||
const connectorEnrollmentQuery = useQuery({
|
||||
queryKey: ["cloud-connector", "enrollment"],
|
||||
queryFn: () => toolsApi.getCloudConnectorEnrollment(),
|
||||
|
|
@ -739,6 +839,14 @@ export function ConnectionSetupFlow({
|
|||
),
|
||||
});
|
||||
const [connectorEnrollmentError, setConnectorEnrollmentError] = useState<string | null>(null);
|
||||
const preserveEnrollmentAccess = useCallback(() => {
|
||||
if (!selectedCompanyId || !requestedAppKey) return;
|
||||
saveEnrollmentAccessState(selectedCompanyId, requestedAppKey, {
|
||||
grantKind,
|
||||
installChoice,
|
||||
agentIds: installChoice === "specific" ? [...installAgentIds] : [],
|
||||
});
|
||||
}, [grantKind, installAgentIds, installChoice, requestedAppKey, selectedCompanyId]);
|
||||
const openConnectorEnrollment = useCallback((verificationUrl: string) => {
|
||||
const target = resolveAuthorizationTarget(verificationUrl);
|
||||
if (!target.ok) {
|
||||
|
|
@ -1158,19 +1266,38 @@ export function ConnectionSetupFlow({
|
|||
setCuratedOAuthClientId("");
|
||||
setCuratedOAuthClientSecret("");
|
||||
setVercelConnector("");
|
||||
const initialMethod = recommendedSetupConnectionMethod(methods);
|
||||
const requestedEntryAdvertisesManagedConnector = requestedEntry.methods.some((candidate) =>
|
||||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||||
|| candidate.oauthStrategy === "paperclip_id_connector"
|
||||
);
|
||||
const initialMethod = (
|
||||
requestedDefinitionUsesManagedConnector && !requestedEntryAdvertisesManagedConnector
|
||||
? recommendedManagedConnectorMethod(fullRequestedDefinition)
|
||||
: null
|
||||
) ?? recommendedSetupConnectionMethod(methods);
|
||||
setConnectionMethodKey(initialMethod?.key ?? "");
|
||||
setConfigValues(defaultMethodConfig(initialMethod));
|
||||
setGoogleSheetsLinks("");
|
||||
setGoogleSheetsError(null);
|
||||
setConnectResult(null);
|
||||
setGrantKind(reconnectGrantKind ?? defaultGrantKindFor(initialMethod));
|
||||
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
|
||||
setInstallChoice(requestedAgentId ? "specific" : "all");
|
||||
const matchingEnrollmentAccess = restoredEnrollmentAccess?.companyId === selectedCompanyId
|
||||
? restoredEnrollmentAccess
|
||||
: null;
|
||||
setGrantKind(reconnectGrantKind ?? matchingEnrollmentAccess?.grantKind ?? defaultGrantKindFor(initialMethod));
|
||||
setInstallAgentIds(new Set(
|
||||
matchingEnrollmentAccess?.agentIds ?? (requestedAgentId ? [requestedAgentId] : []),
|
||||
));
|
||||
setInstallChoice(matchingEnrollmentAccess?.installChoice ?? (requestedAgentId ? "specific" : "all"));
|
||||
// Route/service selection initializes the wizard once. Later renders must
|
||||
// preserve the user's current step in both hosts instead of snapping back
|
||||
// to Access after they continue.
|
||||
setStep(resumeConnectionId ? "key" : "access");
|
||||
setStep(requestedConnectionInitialStep({
|
||||
requestedAppKey,
|
||||
routeStage,
|
||||
resumeConnectionId,
|
||||
hasPrefilledLink: Boolean(prefill.link),
|
||||
zapierSource,
|
||||
}));
|
||||
}
|
||||
|
||||
if (automaticOAuth && (
|
||||
|
|
@ -1200,8 +1327,12 @@ export function ConnectionSetupFlow({
|
|||
reconnectConnectionId,
|
||||
reconnectSourceMatches,
|
||||
resumeConnectionId,
|
||||
fullRequestedDefinition,
|
||||
requestedAppKey,
|
||||
requestedAgentId,
|
||||
restoredEnrollmentAccess,
|
||||
routeStage,
|
||||
zapierSource,
|
||||
]);
|
||||
|
||||
// Resume the exact method and non-secret provider configuration that the
|
||||
|
|
@ -1674,6 +1805,9 @@ export function ConnectionSetupFlow({
|
|||
entry?.name ??
|
||||
(linkName.trim() || defaultGenericMcpName(linkUrl) || "this app");
|
||||
const credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource);
|
||||
const setupCredentialSourceMethods = preEnrollmentManagedMethod
|
||||
? [preEnrollmentManagedMethod]
|
||||
: credentialSourceMethods;
|
||||
const credentialSourceApps = vercelConnectMode
|
||||
? (galleryQuery.data?.apps ?? []).filter(
|
||||
(app) => connectionMethodsForCredentialSource(app, credentialSource).length > 0,
|
||||
|
|
@ -1684,9 +1818,9 @@ export function ConnectionSetupFlow({
|
|||
: null;
|
||||
const stepLabels = zapierSource
|
||||
? ZAPIER_STEP_LABELS
|
||||
: entry && credentialSourceMethods.length > 1
|
||||
: entry && setupCredentialSourceMethods.length > 1
|
||||
? ["Access", "Choose connection"]
|
||||
: entry && credentialSourceMethods[0]?.auth === "oauth"
|
||||
: entry && setupCredentialSourceMethods[0]?.auth === "oauth"
|
||||
? ["Access", "Sign in"]
|
||||
: isGoogleSheetsRobotMethod(entry, connectionMethodKey)
|
||||
? ["Access", "Share sheet"]
|
||||
|
|
@ -1697,8 +1831,8 @@ export function ConnectionSetupFlow({
|
|||
// credential, so it reads the selected method's auth kind.
|
||||
const accessStepMethod = entry
|
||||
? (connectionMethodKey
|
||||
? credentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null
|
||||
: credentialSourceMethods[0] ?? null)
|
||||
? setupCredentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null
|
||||
: setupCredentialSourceMethods[0] ?? null)
|
||||
: null;
|
||||
const accessStepAuthKind: ToolConnectionAuthKind = entry
|
||||
? accessStepMethod?.auth ?? "none"
|
||||
|
|
@ -1781,43 +1915,49 @@ export function ConnectionSetupFlow({
|
|||
|
||||
{step === "key" && entry && showConnectorEnrollmentStep ? (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-muted p-2 text-muted-foreground">
|
||||
<Cloud className="h-5 w-5" />
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-muted p-2 text-muted-foreground">
|
||||
<Cloud className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Connect with Paperclip
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
You must connect this instance to Paperclip to connect to {entry.name} (you only need to do this once).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Connect with Paperclip
|
||||
</h2>
|
||||
|
||||
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
|
||||
<InlineBanner tone="danger" className="mt-4">
|
||||
{connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."}
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-3">
|
||||
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
|
||||
onClick={() => {
|
||||
setConnectorEnrollmentError(null);
|
||||
preserveEnrollmentAccess();
|
||||
const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl;
|
||||
if (verificationUrl) openConnectorEnrollment(verificationUrl);
|
||||
else startConnectorEnrollment.mutate();
|
||||
}}
|
||||
>
|
||||
{startConnectorEnrollment.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{connectorEnrollmentQuery.data?.status === "pending"
|
||||
? "Continue"
|
||||
: "Connect with Paperclip"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
|
||||
<InlineBanner tone="danger" className="mt-4">
|
||||
{connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."}
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-3">
|
||||
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
|
||||
onClick={() => {
|
||||
setConnectorEnrollmentError(null);
|
||||
const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl;
|
||||
if (verificationUrl) openConnectorEnrollment(verificationUrl);
|
||||
else startConnectorEnrollment.mutate();
|
||||
}}
|
||||
>
|
||||
{startConnectorEnrollment.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{connectorEnrollmentQuery.data?.status === "pending"
|
||||
? "Continue"
|
||||
: "Connect with Paperclip"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : step === "key" && entry ? (
|
||||
<KeyStep
|
||||
|
|
@ -3236,7 +3376,9 @@ function KeyStep({
|
|||
? "Checking…"
|
||||
: usingVercel
|
||||
? method?.auth === "oauth" ? "Validate and continue" : "Validate and connect"
|
||||
: method?.auth === "oauth" ? "Continue to sign in" : "Connect"}
|
||||
: method?.auth === "oauth"
|
||||
? entry.slug === "github" ? "Continue to GitHub" : "Continue to sign in"
|
||||
: "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -3449,7 +3591,7 @@ export function AccessStep({
|
|||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
const allAgents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
|
||||
// "Just agents I pick" means agents this person may actually edit. When the server
|
||||
// "Only agents I choose" / "Just agents I pick" means agents this person may actually edit. When the server
|
||||
// has not told us, fall back to every live agent rather than an empty list —
|
||||
// an empty picker would read as "you have no agents".
|
||||
const editableAgentIds = capabilities?.editableAgentIds;
|
||||
|
|
@ -3479,13 +3621,22 @@ export function AccessStep({
|
|||
const lockedAgentName = lockedAgentId
|
||||
? allAgents.find((agent) => agent.id === lockedAgentId)?.name ?? "the requesting agent"
|
||||
: null;
|
||||
const identityHeading = githubIdentity ? "Connect GitHub as" : "Which humans can use this credential?";
|
||||
const agentAccessHeading = grantKind === "agent"
|
||||
? "Which agent owns this GitHub account?"
|
||||
: githubIdentity && grantKind === "user"
|
||||
? "Which agents may use your GitHub when you’re responsible?"
|
||||
: githubIdentity
|
||||
? "Which agents may use the shared GitHub account?"
|
||||
: "Which agents can use this connection?";
|
||||
const agentAccessLabel = githubIdentity ? agentAccessHeading : "Which agents can use this connection?";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="divide-y divide-border">
|
||||
<section className="p-6">
|
||||
<h2 className="text-sm font-semibold text-foreground">{githubIdentity ? "Which GitHub identity should this use?" : "Which humans can use this credential?"}</h2>
|
||||
<h2 className="text-sm font-semibold text-foreground">{identityHeading}</h2>
|
||||
{identityLoading ? (
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2" aria-label="Loading connection identity">
|
||||
<Skeleton className="h-20 w-full rounded-md" />
|
||||
|
|
@ -3500,17 +3651,28 @@ export function AccessStep({
|
|||
) : (
|
||||
<UsersRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{allowedGrantKinds[0] === "user"
|
||||
? githubIdentity ? "My GitHub account" : "Just me"
|
||||
: allowedGrantKinds[0] === "agent"
|
||||
? "A dedicated account for an agent"
|
||||
: "Any human in the company"}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{allowedGrantKinds[0] === "user"
|
||||
? githubIdentity ? "My GitHub account" : "Just me"
|
||||
: allowedGrantKinds[0] === "agent"
|
||||
? "A dedicated account for an agent"
|
||||
: githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company"}
|
||||
</div>
|
||||
{githubIdentity ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{allowedGrantKinds[0] === "user"
|
||||
? "Agents use it only for runs where you are the responsible person."
|
||||
: allowedGrantKinds[0] === "agent"
|
||||
? "That agent always uses this account, regardless of who starts the run."
|
||||
: "Eligible agents use one shared credential, regardless of who starts the run."}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : needsIdentityChoice ? (
|
||||
<RadioCardGroup
|
||||
ariaLabel="Which humans can use this credential?"
|
||||
ariaLabel={githubIdentity ? identityHeading : "Which humans can use this credential?"}
|
||||
className="mt-4 sm:grid-cols-2"
|
||||
value={grantKind}
|
||||
onValueChange={(next) => {
|
||||
|
|
@ -3525,20 +3687,29 @@ export function AccessStep({
|
|||
{
|
||||
value: "user",
|
||||
title: githubIdentity ? "My GitHub account" : "Just me",
|
||||
description: githubIdentity
|
||||
? "Agents use it only for runs where you are the responsible person."
|
||||
: undefined,
|
||||
icon: <UserRound className="h-4 w-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: "agent",
|
||||
title: "A dedicated account for an agent",
|
||||
description: githubIdentity
|
||||
? "That agent always uses this account, regardless of who starts the run."
|
||||
: undefined,
|
||||
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: "organization",
|
||||
title: "Any human in the company",
|
||||
title: githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company",
|
||||
description: githubIdentity
|
||||
? "Eligible agents use one shared credential, regardless of who starts the run."
|
||||
: undefined,
|
||||
icon: <UsersRound className="h-4 w-4" aria-hidden="true" />,
|
||||
accessibleLabel: canCreateOrganizationGrant
|
||||
? "Any human in the company"
|
||||
: `Any human in the company. Unavailable: ${capabilities?.organizationGrantReason ??
|
||||
? githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company"
|
||||
: `${githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company"}. Unavailable: ${capabilities?.organizationGrantReason ??
|
||||
"Only a connection manager can share this credential with the organization."}`,
|
||||
tooltip: canCreateOrganizationGrant
|
||||
? undefined
|
||||
|
|
@ -3556,7 +3727,7 @@ export function AccessStep({
|
|||
</section>
|
||||
|
||||
<section className="p-6">
|
||||
<h2 className="text-sm font-semibold text-foreground">{grantKind === "agent" ? "Which agent owns this GitHub account?" : "Which agents can use this connection?"}</h2>
|
||||
<h2 className="text-sm font-semibold text-foreground">{agentAccessHeading}</h2>
|
||||
{preserveAgentAccess ? (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-md border border-border bg-muted/40 p-4">
|
||||
<UsersRound className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
|
||||
|
|
@ -3576,19 +3747,29 @@ export function AccessStep({
|
|||
grantKind === "agent" ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">Choose exactly one agent. This identity cannot be shared with other agents.</p>
|
||||
) : <RadioCardGroup
|
||||
ariaLabel="Which agents can use this connection?"
|
||||
ariaLabel={agentAccessLabel}
|
||||
className="mt-4 sm:grid-cols-2"
|
||||
value={installChoice}
|
||||
onValueChange={(next) => setInstallChoice(next as "specific" | "all")}
|
||||
options={[
|
||||
{
|
||||
value: "specific",
|
||||
title: "Just agents I pick",
|
||||
title: githubIdentity ? "Only agents I choose" : "Just agents I pick",
|
||||
description: githubIdentity
|
||||
? grantKind === "user"
|
||||
? "Only selected agents may use your GitHub when you’re responsible."
|
||||
: "Only selected agents may use the shared account."
|
||||
: undefined,
|
||||
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
title: "Any agent",
|
||||
description: githubIdentity
|
||||
? grantKind === "user"
|
||||
? "Every agent may use your GitHub when you’re responsible."
|
||||
: "Every agent may use the shared account."
|
||||
: undefined,
|
||||
icon: <BotGroupIcon />,
|
||||
accessibleLabel: canSetCompanyInstall
|
||||
? "Any agent"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ const mockNavigate = vi.hoisted(() => vi.fn());
|
|||
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
|
||||
const mockSearch = vi.hoisted(() => ({ value: "" }));
|
||||
const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined }));
|
||||
const mockCompany = vi.hoisted(() => ({
|
||||
value: {
|
||||
selectedCompanyId: "company-1" as string | undefined,
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" } as { id: string; name: string } | null,
|
||||
},
|
||||
}));
|
||||
|
||||
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!;
|
||||
const GITHUB = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "github")!;
|
||||
|
|
@ -78,10 +84,7 @@ vi.mock("@/lib/router", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
useCompany: () => mockCompany.value,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
|
|
@ -199,6 +202,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.sessionStorage.clear();
|
||||
mockCompany.value = {
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
};
|
||||
mockSearch.value = "";
|
||||
mockParams.appKey = undefined;
|
||||
container = document.createElement("div");
|
||||
|
|
@ -348,8 +356,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).toContain("Which GitHub identity should this use?");
|
||||
expect(container.textContent).toContain("Which agents can use this connection?");
|
||||
expect(container.textContent).toContain("Connect GitHub as");
|
||||
expect(container.textContent).toContain("Which agents may use your GitHub when you’re responsible?");
|
||||
expect(container.textContent).not.toContain("Choose access before adding credentials");
|
||||
expect(container.textContent).not.toContain("Set the identity and agent reach first");
|
||||
expect(container.textContent).not.toContain("Which humans can use this credential?");
|
||||
|
|
@ -360,14 +368,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
|
||||
const myAccount = radios.find((r) => r.textContent?.includes("My GitHub account"));
|
||||
const dedicated = radios.find((r) => r.textContent?.includes("A dedicated account for an agent"));
|
||||
const agentsIPick = radios.find((r) => r.textContent?.includes("Just agents I pick"));
|
||||
const agentsIPick = radios.find((r) => r.textContent?.includes("Only agents I choose"));
|
||||
const anyAgent = radios.find((r) => r.textContent?.includes("Any agent"));
|
||||
expect(myAccount).toBeTruthy();
|
||||
expect(dedicated).toBeTruthy();
|
||||
expect(myAccount?.textContent).toBe("My GitHub account");
|
||||
expect(dedicated?.textContent).toBe("A dedicated account for an agent");
|
||||
expect(agentsIPick?.textContent).toBe("Just agents I pick");
|
||||
expect(anyAgent?.textContent).toBe("Any agent");
|
||||
expect(myAccount?.textContent).toContain("Agents use it only for runs where you are the responsible person.");
|
||||
expect(dedicated?.textContent).toContain("That agent always uses this account, regardless of who starts the run.");
|
||||
expect(agentsIPick?.textContent).toContain("Only selected agents may use your GitHub when you’re responsible.");
|
||||
expect(anyAgent?.textContent).toContain("Every agent may use your GitHub when you’re responsible.");
|
||||
expect(myAccount?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
|
||||
expect(dedicated?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
|
||||
expect(agentsIPick?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
|
||||
|
|
@ -408,7 +416,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll('[role="radio"]'))
|
||||
.find((r) => r.textContent?.includes("Just agents I pick"))
|
||||
.find((r) => r.textContent?.includes("Only agents I choose"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
|
@ -703,6 +711,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Connect with Paperclip");
|
||||
expect(container.textContent).toContain(
|
||||
"You must connect this instance to Paperclip to connect to Gmail (you only need to do this once).",
|
||||
);
|
||||
expect(buttonByText("Connect with Paperclip")?.closest(".rounded-xl")?.classList.contains("border-border")).toBe(true);
|
||||
expect(container.textContent).not.toContain("Required once for managed Google sign-in.");
|
||||
expect(container.textContent).not.toContain("Your OAuth app");
|
||||
|
||||
|
|
@ -721,6 +733,173 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("keeps GitHub's personal identity defaults while its managed method awaits enrollment", async () => {
|
||||
mockParams.appKey = "github";
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [{
|
||||
...GITHUB,
|
||||
methods: GITHUB.methods.filter((method) => !method.oauthStrategy),
|
||||
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
|
||||
}],
|
||||
});
|
||||
getCloudConnectorEnrollmentMock.mockResolvedValueOnce({
|
||||
configured: false,
|
||||
status: "not_configured",
|
||||
brokerBaseUrl: "https://my-staging.paperclip.app",
|
||||
instanceId: null,
|
||||
environment: "staging",
|
||||
origins: [],
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Access · Sign in");
|
||||
expect(radioContaining("My GitHub account")?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(container.textContent).toContain("Which agents may use your GitHub when you’re responsible?");
|
||||
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Connect with Paperclip");
|
||||
expect(container.textContent).not.toContain("GitHub token");
|
||||
});
|
||||
|
||||
it("restores the setup step after the one-time enrollment callback", async () => {
|
||||
mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled";
|
||||
listGalleryMock.mockResolvedValueOnce({ apps: [GITHUB_MANAGED] });
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Step 2 of 2");
|
||||
expect(container.textContent).toContain("Continue to GitHub");
|
||||
expect(container.textContent).not.toContain("Connect GitHub as");
|
||||
expect(container.textContent).not.toContain("Connect with Paperclip");
|
||||
});
|
||||
|
||||
it("preserves a dedicated agent identity across the full-page enrollment callback", async () => {
|
||||
mockParams.appKey = "github";
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [{
|
||||
...GITHUB,
|
||||
methods: GITHUB.methods.filter((method) => !method.oauthStrategy),
|
||||
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
|
||||
}],
|
||||
capabilities: {
|
||||
canCreateOrganizationGrant: true,
|
||||
organizationGrantReason: null,
|
||||
canSetCompanyInstall: true,
|
||||
companyInstallReason: null,
|
||||
},
|
||||
});
|
||||
getCloudConnectorEnrollmentMock.mockResolvedValue({
|
||||
configured: false,
|
||||
status: "not_configured",
|
||||
brokerBaseUrl: "https://my-staging.paperclip.app",
|
||||
instanceId: null,
|
||||
environment: "staging",
|
||||
origins: [],
|
||||
});
|
||||
|
||||
await render();
|
||||
await act(async () => {
|
||||
radioContaining("A dedicated account for an agent")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Select agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Allow Ada"]')
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
const accessContinue = buttonByText("Save and continue") ?? buttonByText("Continue to GitHub");
|
||||
expect(accessContinue?.disabled).toBe(false);
|
||||
await act(async () => {
|
||||
accessContinue?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Connect with Paperclip")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(JSON.parse(window.sessionStorage.getItem(
|
||||
"paperclip.connector-enrollment-access:github",
|
||||
) ?? "null")).toEqual({
|
||||
companyId: "company-1",
|
||||
grantKind: "agent",
|
||||
installChoice: "specific",
|
||||
agentIds: ["agent-1"],
|
||||
});
|
||||
|
||||
await act(async () => mountedRoot?.unmount());
|
||||
mountedRoot = null;
|
||||
container.innerHTML = "";
|
||||
mockParams.appKey = undefined;
|
||||
mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled";
|
||||
mockCompany.value = { selectedCompanyId: undefined, selectedCompany: null };
|
||||
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
|
||||
getCloudConnectorEnrollmentMock.mockResolvedValue({
|
||||
configured: true,
|
||||
status: "active",
|
||||
brokerBaseUrl: "https://my-staging.paperclip.app",
|
||||
instanceId: "inst-test",
|
||||
environment: "staging",
|
||||
origins: ["https://paperclip.example.test"],
|
||||
});
|
||||
|
||||
const coldLoadClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await render(coldLoadClient);
|
||||
|
||||
expect(window.sessionStorage.getItem("paperclip.connector-enrollment-access:github")).toBeNull();
|
||||
mockCompany.value = {
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
};
|
||||
await act(async () => {
|
||||
mountedRoot?.render(
|
||||
<QueryClientProvider client={coldLoadClient}>
|
||||
<AppsConnect />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Step 2 of 2");
|
||||
expect(window.sessionStorage.getItem(
|
||||
"paperclip.connector-enrollment-access:github",
|
||||
)).toBeNull();
|
||||
await act(async () => {
|
||||
buttonByText("Continue to GitHub")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
galleryKey: "github",
|
||||
grantKind: "agent",
|
||||
subjectAgentId: "agent-1",
|
||||
}));
|
||||
});
|
||||
|
||||
it("never renders self-host enrollment when the connector identity is already active", async () => {
|
||||
mockSearch.value = "source=gmail&stage=setup";
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [{
|
||||
...GMAIL,
|
||||
methods: GMAIL.methods.filter((method) => !method.oauthStrategy),
|
||||
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
|
||||
}],
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).not.toContain("You must connect this instance to Paperclip");
|
||||
expect(container.textContent).not.toContain("Connect with Paperclip");
|
||||
});
|
||||
|
||||
it("keeps Google Drive prerequisites off access and defaults to its write-capable method", async () => {
|
||||
mockParams.appKey = "google-drive";
|
||||
listGalleryMock.mockResolvedValue({ apps: [GOOGLE_DRIVE] });
|
||||
|
|
@ -844,7 +1023,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
// A deep-linked app lands on Access first: identity and reach are chosen
|
||||
// before the credential (PAP-17835).
|
||||
expect(container.textContent).toContain("Which GitHub identity should this use?");
|
||||
expect(container.textContent).toContain("Connect GitHub as");
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
|
|
@ -2199,7 +2378,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Which GitHub identity should this use?");
|
||||
expect(container.textContent).toContain("Connect GitHub as");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
|
|
|||
Loading…
Reference in New Issue