mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
feat(connections): one Advanced disclosure, ranked default, armed write gate
Completes PAP-659 plan steps C0/C1/C2 and C6/C7 on this worktree. - Fold the key step's own Advanced settings into the same disclosure that holds the access controls, so every connector has exactly one Advanced in the same place rather than two on one screen. - Keep the disclosure open when what is inside it is load-bearing: a non-default method in use, or a selection the connector still needs. - Restore first-attempt vs retry semantics on the single primary action: the first press creates or resumes, and only a retry pays for the defensive re-read that recovers a create whose response was lost. - Show the resolved default on the one-time Cloud enrolment screen too, and state every reason the default cannot apply rather than only the first. - Show a loading state when a deep link arrives before the gallery resolves its definition; the deleted Access step used to carry that wait. - C6a: recommendedDefaultsForApp now returns askFirstRiskLevels ["write","destructive","high","critical"] instead of [], arming the approval gate that was already built but had nothing behind it. - C6b/C7: the Permissions tab gains a per-group bulk control and shows each action's risk level, so a tool the classifier misfiled is visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c2205a88b4
commit
b63aef204c
6 files changed
+220
-133
No files matched your search
@@ -617,12 +617,12 @@ describe("AppDefinition catalog", () => {
|
||||
APP_DEFINITIONS.find((app) => app.slug === "hugging-face")?.methods[0]
|
||||
?.defaults?.scopesHint,
|
||||
).toEqual(["read-mcp"]));
|
||||
it("defaults every new connection action to allowed", () => {
|
||||
it("defaults reads to allowed and state-changing actions to ask first", () => {
|
||||
for (const app of APP_DEFINITIONS)
|
||||
for (const method of app.methods)
|
||||
expect(recommendedDefaultsForApp(app, method.key)).toEqual({
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: [],
|
||||
askFirstRiskLevels: ["write", "destructive", "high", "critical"],
|
||||
});
|
||||
});
|
||||
it("defaults explicit read/write capability groups to their write-capable method", () => {
|
||||
|
||||
@@ -246,16 +246,33 @@ export function resolveConnectionMethodServerUrl(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk levels a fresh connection gates behind human approval (PAP-659 C6a).
|
||||
*
|
||||
* The runtime gate and its approval card already exist; what was missing was a
|
||||
* default with anything behind it. Reads stay Allowed so the constant read
|
||||
* traffic an agent does is never interrupted; anything that changes the
|
||||
* provider's state asks first, and one click on the Permissions tab turns that
|
||||
* off per connection.
|
||||
*
|
||||
* Two risk vocabularies are in use — `read|write|destructive` from the tool
|
||||
* classifier and `low|medium|high|critical` from curated catalogs — so both
|
||||
* are named rather than assuming callers normalise them.
|
||||
*/
|
||||
export const DEFAULT_ASK_FIRST_RISK_LEVELS = [
|
||||
"write",
|
||||
"destructive",
|
||||
"high",
|
||||
"critical",
|
||||
] as const;
|
||||
|
||||
export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record<string, unknown> {
|
||||
// Keep the parameters in the public contract: callers resolve defaults for a
|
||||
// concrete app/method even though the initial policy is now uniform. This is
|
||||
// an open default, not an approval bypass: connection finalization remains a
|
||||
// configure-authorized, audited operation, and Ask first stays available as
|
||||
// an operator-selected policy for any action after the connection is made.
|
||||
// concrete app/method even though the initial policy is uniform.
|
||||
void app;
|
||||
void methodKey;
|
||||
return {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: [],
|
||||
askFirstRiskLevels: [...DEFAULT_ASK_FIRST_RISK_LEVELS],
|
||||
};
|
||||
}
|
||||
@@ -329,6 +329,7 @@ export {
|
||||
getRecommendedConnectionMethod,
|
||||
isConnectableAppSlug,
|
||||
isAppStoreVisibleSlug,
|
||||
DEFAULT_ASK_FIRST_RISK_LEVELS,
|
||||
recommendedDefaultsForApp,
|
||||
resolveConnectionMethodServerUrl,
|
||||
} from "./app-definitions.js";
|
||||
|
||||
@@ -1918,8 +1918,12 @@ function StandardConnectionSetupFlow({
|
||||
: "api_key";
|
||||
// PAP-659 C0: the resolved default is stated, not asked. `Change` opens the
|
||||
// same controls the deleted Access step owned, inline and never blocking.
|
||||
const connectionDefaults = step === "key" ? (
|
||||
const renderConnectionDefaults = step === "key" ? (
|
||||
(extra?: ReactNode, forceOpen?: boolean) => (
|
||||
<ConnectionAccessDefaults
|
||||
key="connection-defaults"
|
||||
extra={extra}
|
||||
forceOpen={forceOpen}
|
||||
companyId={selectedCompanyId}
|
||||
sentence={connectionDefaultSummarySentence({
|
||||
grantKind: effectiveGrantKind,
|
||||
@@ -1929,17 +1933,17 @@ function StandardConnectionSetupFlow({
|
||||
lockedAgentId: requestedAgentId ?? null,
|
||||
preserveAgentAccess: Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection)),
|
||||
})}
|
||||
notice={
|
||||
notice={[
|
||||
galleryQuery.data?.capabilities?.canCreateOrganizationGrant === false
|
||||
? galleryQuery.data.capabilities.organizationGrantReason
|
||||
?? "Only a connection manager can share this credential with the organization."
|
||||
: galleryQuery.data?.capabilities?.canSetCompanyInstall === false
|
||||
? galleryQuery.data.capabilities.companyInstallReason
|
||||
?? "Only someone who can configure this connection can give every agent access."
|
||||
: requestedAgentId
|
||||
? "This task grants access only to the agent that asked for it."
|
||||
: null
|
||||
}
|
||||
: null,
|
||||
galleryQuery.data?.capabilities?.canSetCompanyInstall === false
|
||||
? galleryQuery.data.capabilities.companyInstallReason
|
||||
?? "Only someone who can configure this connection can give every agent access."
|
||||
: null,
|
||||
requestedAgentId ? "This task grants access only to the agent that asked for it." : null,
|
||||
].filter((reason): reason is string => Boolean(reason))}
|
||||
authKind={accessStepAuthKind}
|
||||
grantKinds={fixedGrantKind ? [fixedGrantKind] : accessStepMethod?.grantKinds}
|
||||
grantKind={effectiveGrantKind}
|
||||
@@ -1955,7 +1959,9 @@ function StandardConnectionSetupFlow({
|
||||
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
|
||||
disabled={connectMutation.isPending || oauthStartMutation.isPending}
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
const connectionDefaults = renderConnectionDefaults?.() ?? null;
|
||||
|
||||
const showCuratedOAuthState = Boolean(
|
||||
automaticOAuthEntry
|
||||
@@ -2007,6 +2013,8 @@ function StandardConnectionSetupFlow({
|
||||
defaults={connectionDefaults}
|
||||
onOpenAuthorization={openAuthorizationTab}
|
||||
onRetry={async () => {
|
||||
const firstAttempt = !directOAuthAccessConfirmedRef.current;
|
||||
directOAuthAccessConfirmedRef.current = true;
|
||||
setOAuthError(null);
|
||||
setOAuthPhase("starting");
|
||||
const connection = connectResult?.connection ?? resumableOAuthConnection;
|
||||
@@ -2014,6 +2022,15 @@ function StandardConnectionSetupFlow({
|
||||
startOAuth(connection);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
firstAttempt
|
||||
&& !resumeConnectionId
|
||||
&& !applicationsQuery.isError
|
||||
&& !connectionsQuery.isError
|
||||
) {
|
||||
connectApp(automaticOAuthEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
// The create request may have reached the server even when its
|
||||
// response did not reach the browser. Re-read both resources before
|
||||
@@ -2274,6 +2291,7 @@ function StandardConnectionSetupFlow({
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
{connectionDefaults}
|
||||
<div className="mt-6 flex items-center justify-between gap-3">
|
||||
<Button type="button" variant="ghost" onClick={backToGallery}>
|
||||
Back
|
||||
@@ -2339,7 +2357,7 @@ function StandardConnectionSetupFlow({
|
||||
setGoogleSheetsError(null);
|
||||
}}
|
||||
submitting={connectMutation.isPending}
|
||||
defaults={connectionDefaults}
|
||||
renderDefaults={renderConnectionDefaults}
|
||||
// Back returns to the connector gallery for new, resumed, and
|
||||
// reconnected accounts. Cancel is the separate exit to the app list.
|
||||
onBack={backToGallery}
|
||||
@@ -2387,6 +2405,17 @@ function StandardConnectionSetupFlow({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* A deep link can arrive before the gallery resolves its definition.
|
||||
The Access step used to carry that wait; without it the connect screen
|
||||
has to show the wait itself rather than render nothing. */}
|
||||
{step === "key" && !entry && !linkUrl && !zapierSource && requestedAppKey && (
|
||||
<div className="mx-auto max-w-xl" aria-busy="true" aria-label={`Loading ${requestedAppKey} setup`}>
|
||||
<Skeleton className="h-6 w-2/3 rounded-md" />
|
||||
<Skeleton className="mt-3 h-4 w-full rounded-md" />
|
||||
<Skeleton className="mt-8 h-11 w-40 rounded-md" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "key" && !entry && linkUrl && !zapierSource && (
|
||||
<LinkConnectStep
|
||||
link={linkUrl}
|
||||
@@ -3365,7 +3394,7 @@ function KeyStep({
|
||||
googleSheetsError,
|
||||
onGoogleSheetsLinksChange,
|
||||
submitting,
|
||||
defaults,
|
||||
renderDefaults,
|
||||
onBack,
|
||||
onConnect,
|
||||
}: {
|
||||
@@ -3393,8 +3422,11 @@ function KeyStep({
|
||||
googleSheetsError: string | null;
|
||||
onGoogleSheetsLinksChange: (next: string) => void;
|
||||
submitting: boolean;
|
||||
/** The stated default and its Advanced disclosure (PAP-659 C0). */
|
||||
defaults?: ReactNode;
|
||||
/**
|
||||
* Builds the one Advanced disclosure this screen has (PAP-659 C0). The key
|
||||
* step contributes its own settings to it rather than opening a second one.
|
||||
*/
|
||||
renderDefaults?: (extra?: ReactNode, forceOpen?: boolean) => ReactNode;
|
||||
onBack: () => void;
|
||||
onConnect: () => void;
|
||||
}) {
|
||||
@@ -3472,7 +3504,6 @@ function KeyStep({
|
||||
const configFields = allConfigFields.filter((field) => !field.hidden);
|
||||
const standardConfigFields = configFields.filter((field) => field.advanced !== true);
|
||||
const advancedConfigFields = configFields.filter((field) => field.advanced === true);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const configFilled = allConfigFields.every((field) => {
|
||||
if (!field.required) return true;
|
||||
const value = configValues[field.key];
|
||||
@@ -3558,13 +3589,39 @@ function KeyStep({
|
||||
{!method && <p className="mt-2 text-xs text-muted-foreground">Choose a connection method to continue.</p>}
|
||||
</div>
|
||||
) : null;
|
||||
const advancedMethodSelection = authenticationSelection;
|
||||
const hasAdvancedSettings = advancedConfigFields.length > 0
|
||||
|| optionalCustomerOAuthClient
|
||||
|| hasAlternateMethods;
|
||||
// Keep the disclosure open when what is inside it is load-bearing right now:
|
||||
// a non-default method in use, or a selection the connector still needs.
|
||||
const forceAdvancedOpen = usingCustomGoogleOAuth || !hasMethodSelection;
|
||||
const advancedSettings = hasAdvancedSettings ? (
|
||||
<div className="space-y-6">
|
||||
{capabilitySelection}
|
||||
{authenticationSelection}
|
||||
{advancedConfigFields.map((field) => (
|
||||
<MethodConfigField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={configValues[field.key]}
|
||||
onChange={(value) => onConfigChange({ ...configValues, [field.key]: value })}
|
||||
/>
|
||||
))}
|
||||
{optionalCustomerOAuthClient ? (
|
||||
<OAuthClientFields
|
||||
entry={entry}
|
||||
method={method!}
|
||||
callbackUrl={oauthCallbackUrl}
|
||||
clientId={oauthClientId}
|
||||
onClientIdChange={onOAuthClientIdChange}
|
||||
clientSecret={oauthClientSecret}
|
||||
onClientSecretChange={onOAuthClientSecretChange}
|
||||
required={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
const defaults = renderDefaults?.(advancedSettings, forceAdvancedOpen) ?? null;
|
||||
|
||||
if (isGoogleSheetsRobotMethod(entry, method)) {
|
||||
const parsed = parseGoogleSheetIds(googleSheetsLinks);
|
||||
@@ -3694,40 +3751,6 @@ function KeyStep({
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasAdvancedSettings && (
|
||||
<Collapsible open={advancedOpen || forceAdvancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform", advancedOpen && "rotate-180")} />
|
||||
Advanced
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<div className="space-y-6">
|
||||
{capabilitySelection}
|
||||
{advancedMethodSelection}
|
||||
{advancedConfigFields.map((field) => (
|
||||
<MethodConfigField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={configValues[field.key]}
|
||||
onChange={(value) => onConfigChange({ ...configValues, [field.key]: value })}
|
||||
/>
|
||||
))}
|
||||
{optionalCustomerOAuthClient ? (
|
||||
<OAuthClientFields
|
||||
entry={entry}
|
||||
method={method!}
|
||||
callbackUrl={oauthCallbackUrl}
|
||||
clientId={oauthClientId}
|
||||
onClientIdChange={onOAuthClientIdChange}
|
||||
clientSecret={oauthClientSecret}
|
||||
onClientSecretChange={onOAuthClientSecretChange}
|
||||
required={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{!usingVercel && method?.auth === "oauth" && customerOAuthClientRequired ? (
|
||||
<div id={googleOAuthFieldsId} role="region" aria-label="Your OAuth app">
|
||||
@@ -4310,16 +4333,27 @@ export function ConnectionAccessDefaults({
|
||||
companyId,
|
||||
sentence,
|
||||
notice,
|
||||
extra,
|
||||
forceOpen = false,
|
||||
disabled = false,
|
||||
...accessProps
|
||||
}: Omit<Parameters<typeof AccessStepContent>[0], "agents" | "agentsLoading" | "submitLabel" | "onBack" | "onContinue" | "pending" | "continuesToProvider" | "hideFooter" | "bare"> & {
|
||||
companyId: string;
|
||||
sentence: string;
|
||||
/** Shown when the resolved default genuinely cannot apply. Never a step. */
|
||||
notice?: string | null;
|
||||
notice?: string[];
|
||||
/**
|
||||
* Connector-specific advanced settings — alternate methods, access level,
|
||||
* provider config, a customer-owned OAuth client. They share this one
|
||||
* disclosure rather than opening a second one on the same screen.
|
||||
*/
|
||||
extra?: ReactNode;
|
||||
/** Something inside is load-bearing right now, so do not hide it. */
|
||||
forceOpen?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const expanded = open || forceOpen;
|
||||
return (
|
||||
<div className="mt-6 rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||
@@ -4328,21 +4362,24 @@ export function ConnectionAccessDefaults({
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto p-0 text-xs font-semibold underline underline-offset-2"
|
||||
aria-expanded={open}
|
||||
aria-expanded={expanded}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((previous) => !previous)}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
{notice ? <p className="mt-2 text-xs text-muted-foreground">{notice}</p> : null}
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
{(notice ?? []).map((reason) => (
|
||||
<p key={reason} className="mt-2 text-xs text-muted-foreground">{reason}</p>
|
||||
))}
|
||||
<Collapsible open={expanded} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="mt-1 inline-flex items-center gap-1 text-xs font-semibold text-muted-foreground hover:text-foreground">
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" /> : <ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" /> : <ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
Advanced
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-3">
|
||||
<div className="mt-3 space-y-6">
|
||||
{extra}
|
||||
<AccessStep {...accessProps} companyId={companyId} bare hideFooter submitLabel="" onBack={() => {}} onContinue={() => {}} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
@@ -630,7 +630,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
expect(radioContaining("Any human in the organization")?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(radioContaining("Just agents I pick")).toBeTruthy();
|
||||
expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(container.textContent).not.toContain("Does it need a key?");
|
||||
});
|
||||
|
||||
it.each([false, true])("gates chat-only cards in the embedded tool gallery without hiding GitHub (%s)", async (enabled) => {
|
||||
@@ -672,6 +671,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
expect(buttonByText("Use GitHub")).toBeTruthy();
|
||||
await act(async () => buttonByText("Use GitHub")!.click());
|
||||
await flushReact();
|
||||
await openAccessAdvanced();
|
||||
expect(container.textContent).toContain("Connect GitHub as");
|
||||
expect(container.textContent).toContain("My GitHub account");
|
||||
expect(container.textContent).not.toContain("Chat with an agent");
|
||||
@@ -701,7 +701,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
client.setQueryData(queryKeys.health, { deploymentMode: "authenticated", localAiLoginSupported: false });
|
||||
await render(client, false, taskRepair ? <ConnectionSetupFlow host="dialog" serviceSlug="anthropic" interactionId="ai-intent" requestedAgentId="agent-1" aiConnection={{ provider: "anthropic", method: "subscription", mode: "responsible_user" }} /> : undefined);
|
||||
await passAccessStep();
|
||||
expect(container.textContent).toContain("Connect account");
|
||||
expect(container.textContent).toContain("Connection name");
|
||||
expect(container.textContent).not.toContain("How do you want to connect?");
|
||||
expect(radioContaining("Use an API key")).toBeUndefined();
|
||||
@@ -814,17 +813,17 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll('[role="radio"]'))
|
||||
.find((r) => r.textContent?.includes("A dedicated account for an agent"))
|
||||
.find((r) => r.textContent?.includes("Only agents I choose"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// The sentence above the primary action is the only place this is said, so
|
||||
// it has to track the controls rather than print a fixed default.
|
||||
expect(container.textContent).toContain("Connects as a dedicated agent account");
|
||||
expect(container.textContent).toContain("no agents selected yet");
|
||||
|
||||
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
|
||||
expect(radios.find((r) => r.textContent?.includes("A dedicated account for an agent"))
|
||||
expect(radios.find((r) => r.textContent?.includes("Only agents I choose"))
|
||||
?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
@@ -911,6 +910,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
mockParams.appKey = "posthog";
|
||||
mockSearch.value = "";
|
||||
root = await render();
|
||||
await openAccessAdvanced();
|
||||
|
||||
const posthog = identityChoices();
|
||||
expect(posthog.justMe?.getAttribute("aria-checked")).toBe("false");
|
||||
@@ -985,7 +985,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
|
||||
await render();
|
||||
await passAccessStep();
|
||||
await openAccessAdvanced();
|
||||
|
||||
const full = radioContaining("Full");
|
||||
const code = radioContaining("Code");
|
||||
@@ -1038,9 +1038,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
|
||||
await render();
|
||||
|
||||
expect(document.body.textContent).toContain("Step 1 of 2");
|
||||
expect(document.body.textContent).toContain("Access · Choose connection");
|
||||
// PAP-659: a selected app has one screen, so no stepper at all.
|
||||
expect(document.body.textContent).not.toContain("Step 1 of 2");
|
||||
expect(document.body.textContent).not.toContain("Pick app ·");
|
||||
expect(document.body.textContent).toContain("Connect Gmail");
|
||||
});
|
||||
|
||||
it("opens a brokered Gmail deep link at the access step", async () => {
|
||||
@@ -1135,8 +1136,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
if (!popupBlocked) expect(popup.close).toHaveBeenCalled();
|
||||
await openAccessAdvanced();
|
||||
expect(container.textContent).toContain("What should Paperclip be able to do?");
|
||||
expect(container.textContent).toContain("Step 2 of 2");
|
||||
connectAppMock.mockResolvedValue({ connectionId: "gmail-1", connection: { id: "gmail-1", credentialPolicy: "per_user" }, auth: { kind: "oauth", startUrl: "https://example.test/unbound" } });
|
||||
await act(async () => buttonByText("Continue to sign in")?.click());
|
||||
await flushReact();
|
||||
@@ -1206,7 +1207,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
|
||||
await render();
|
||||
expect(container.textContent).toContain("Step 2 of 2");
|
||||
await act(async () => {
|
||||
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
@@ -1224,23 +1224,21 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("labels GitHub's local setup transition without promising a provider handoff", async () => {
|
||||
it("labels GitHub's setup transition as the provider handoff it now is", async () => {
|
||||
mockSearch.value = "source=github";
|
||||
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
|
||||
|
||||
await render();
|
||||
|
||||
const continueButton = buttonByText("Continue");
|
||||
expect(continueButton).toBeDefined();
|
||||
expect(continueButton?.disabled).toBe(false);
|
||||
expect(continueButton?.querySelector(".lucide-arrow-up-right")).toBeNull();
|
||||
expect(buttonByText("Continue to GitHub")).toBeUndefined();
|
||||
// PAP-659: the screen that used to precede the handoff is gone, so the one
|
||||
// primary action names the provider instead of a generic "Continue".
|
||||
expect(buttonByText("Continue")).toBeUndefined();
|
||||
const handoff = buttonByText("Continue to GitHub");
|
||||
expect(handoff).toBeDefined();
|
||||
expect(handoff?.disabled).toBe(false);
|
||||
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Step 2 of 2");
|
||||
await openAccessAdvanced();
|
||||
expect(container.textContent).toContain("How do you want to connect?");
|
||||
expect(buttonByText("Continue to GitHub")).toBeDefined();
|
||||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1276,11 +1274,13 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Access · Sign in");
|
||||
expect(container.textContent).toContain("Connects as you");
|
||||
await openAccessAdvanced();
|
||||
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?");
|
||||
expect(buttonByText("Continue")).toBeDefined();
|
||||
// Enrolment is the screen; its own action is the only primary one.
|
||||
expect(buttonByText("Connect with Paperclip")).toBeDefined();
|
||||
expect(buttonByText("Continue to GitHub")).toBeUndefined();
|
||||
|
||||
await passAccessStep();
|
||||
@@ -1295,7 +1295,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
|
||||
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");
|
||||
@@ -1388,6 +1387,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
|
||||
await render();
|
||||
await openAccessAdvanced();
|
||||
await act(async () => {
|
||||
radioContaining("A dedicated account for an agent")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
@@ -1401,12 +1401,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
const accessContinue = buttonByText("Continue");
|
||||
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 }));
|
||||
});
|
||||
@@ -1455,7 +1449,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Step 2 of 2");
|
||||
expect(container.textContent).toContain("Connects as a dedicated agent account");
|
||||
expect(window.sessionStorage.getItem(
|
||||
"paperclip.connector-enrollment-access:github",
|
||||
)).toBeNull();
|
||||
@@ -1489,11 +1483,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
...definition, ownershipAvailability: { ...definition.ownershipAvailability, platform_shared: true },
|
||||
}] });
|
||||
await render();
|
||||
await openAccessAdvanced();
|
||||
if (!enrollmentReturn) {
|
||||
await act(async () => {
|
||||
radioContaining("Just me")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await passAccessStep();
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
if (definition.methods.some((method) => method.capabilityProfile?.key !== "read")) {
|
||||
@@ -1505,7 +1500,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
}
|
||||
await flushReact();
|
||||
// Change auth methods too, including apps with only one capability.
|
||||
expect(container.textContent).not.toContain("How do you want to connect?");
|
||||
expect(container.textContent).not.toContain("Connect with Paperclip");
|
||||
expect(container.textContent).not.toContain("Your OAuth app");
|
||||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||||
@@ -1565,6 +1559,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
expect(container.textContent).not.toContain("does not enable unrelated Paperclip customers");
|
||||
expect(container.textContent).not.toContain("final project-registration email");
|
||||
expect(container.textContent).not.toContain("Apply or verify Developer Preview enrollment");
|
||||
await openAccessAdvanced();
|
||||
expect(radioContaining("Just me")).toBeTruthy();
|
||||
expect(radioContaining("Any human in the organization")?.getAttribute("aria-checked")).toBe("true");
|
||||
await passAccessStep();
|
||||
@@ -1587,6 +1582,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
(heading) => heading.textContent?.trim() === "Connect Google Calendar",
|
||||
);
|
||||
expect(duplicateHeadings).toHaveLength(1);
|
||||
await openAccessAdvanced();
|
||||
expect(container.textContent).toContain("What should Paperclip be able to do?");
|
||||
expect(container.textContent).toContain("Review requirements");
|
||||
expect(container.textContent).not.toContain("Connect Google Calendar to read and manage events.");
|
||||
@@ -1639,6 +1635,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
},
|
||||
});
|
||||
await render();
|
||||
await openAccessAdvanced();
|
||||
|
||||
const anyAgent = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
@@ -1668,18 +1665,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
).find((r) => r.textContent?.includes("Just agents I pick"));
|
||||
expect(pick?.disabled).toBe(false);
|
||||
// Continue refuses the forbidden choice even though it is the current one.
|
||||
expect(buttonByText("Continue")?.disabled).toBe(true);
|
||||
// PAP-659: a reach the member cannot grant is reported above the primary
|
||||
// action rather than disabling it, and "Just agents I pick" is the live
|
||||
// alternative, so the screen is not a dead end.
|
||||
expect(container.textContent).toContain(
|
||||
"Your company policy limits this choice to connection managers.",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the selected app directly on its setup route", async () => {
|
||||
mockParams.appKey = "github";
|
||||
await render();
|
||||
|
||||
// A deep-linked app lands on Access first: identity and reach are chosen
|
||||
// before the credential (PAP-17835).
|
||||
// PAP-659: a deep-linked app lands on its one connect screen. Identity and
|
||||
// reach are stated there and changed in the same Advanced disclosure.
|
||||
expect(container.textContent).toContain("Connects for everyone in your organization");
|
||||
await openAccessAdvanced();
|
||||
expect(container.textContent).toContain("Connect GitHub as");
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
|
||||
@@ -1692,12 +1694,13 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
// PAP-659: identity is stated, not asked, and the method chooser moved
|
||||
// into the same Advanced disclosure.
|
||||
expect(container.textContent).toContain("Connects for everyone in your organization");
|
||||
await passAccessStep();
|
||||
await openAccessAdvanced();
|
||||
|
||||
expect(container.textContent).toContain("How do you want to connect?");
|
||||
expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("false");
|
||||
// PAP-659 C1: OAuth is strictly less work than a key, so it is preselected.
|
||||
expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(radioContaining("Use a personal API key")?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(buttonByText("Connect")?.disabled).toBe(true);
|
||||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
radioContaining("Use a personal API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
@@ -1710,20 +1713,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
const keyInput = container.querySelector<HTMLInputElement>('input[type="password"]');
|
||||
const advanced = buttonByText("Advanced");
|
||||
expect(keyInput).toBeTruthy();
|
||||
expect(container.querySelector<HTMLInputElement>('input[placeholder="Optional numeric project ID"]')).toBeNull();
|
||||
expect(container.querySelector('[role="switch"]')).toBeNull();
|
||||
expect(advanced?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(container.textContent).not.toContain("Pin to project ID");
|
||||
expect(container.textContent).not.toContain("Feature groups");
|
||||
expect(container.textContent).not.toContain("Individual tools");
|
||||
expect(container.textContent).not.toContain("Tool response mode");
|
||||
|
||||
await act(async () => {
|
||||
advanced?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(advanced?.getAttribute("aria-expanded")).toBe("true");
|
||||
// PAP-659: the optional controls share the one Advanced disclosure with
|
||||
// the access defaults, so they are visible because it is already open —
|
||||
// and they are still optional, so none of them blocks Connect.
|
||||
expect(advanced).toBeTruthy();
|
||||
expect(container.textContent).toContain("Pin to project ID");
|
||||
expect(container.querySelector<HTMLInputElement>('input[placeholder="Optional numeric project ID"]')).toBeTruthy();
|
||||
expect(container.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe("false");
|
||||
@@ -1774,7 +1767,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
},
|
||||
});
|
||||
await render(undefined, false, <AppsConnect credentialSource="vercel_connect" />);
|
||||
await passAccessStep();
|
||||
await openAccessAdvanced();
|
||||
await act(async () => {
|
||||
radioContaining("Use a personal API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
@@ -1882,7 +1875,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
));
|
||||
expect(container.textContent).toContain("available to the agent that asked for it");
|
||||
const continueButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Continue",
|
||||
(button) => button.textContent?.trim().startsWith("Continue"),
|
||||
);
|
||||
expect(continueButton).toBeTruthy();
|
||||
expect(continueButton?.disabled).toBe(false);
|
||||
@@ -2365,7 +2358,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
|
||||
await render();
|
||||
expect(container.textContent).toContain("Any human in the organization");
|
||||
expect(container.textContent).toContain("Connects for everyone in your organization");
|
||||
await passAccessStep();
|
||||
await submitCuratedOAuthSetup();
|
||||
|
||||
@@ -2615,12 +2608,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
// PAP-659: Try again re-reads and then resumes the draft it finds. There
|
||||
// is no Access step to bounce back through, and the operator already
|
||||
// pressed the one primary action, so recovery must not ask again.
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
|
||||
expect(container.textContent).toContain("agent access stays as it is");
|
||||
await passAccessStep();
|
||||
await submitCuratedOAuthSetup();
|
||||
expect(startOAuthMock).toHaveBeenCalledWith("conn-after-retry", {
|
||||
asCurrentUser: true,
|
||||
});
|
||||
@@ -3021,7 +3013,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
await openAccessAdvanced();
|
||||
await act(async () => {
|
||||
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
@@ -3047,7 +3039,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
await openAccessAdvanced();
|
||||
await act(async () => {
|
||||
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
@@ -3075,7 +3067,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
expect(container.querySelector('input[placeholder="My app"]')).toBeNull();
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=github&stage=setup");
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=github");
|
||||
});
|
||||
|
||||
it("keeps the originating connection intent in wizard URLs", async () => {
|
||||
@@ -3092,9 +3084,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
);
|
||||
|
||||
await passAccessStep();
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
`/apps/connect?source=github&stage=setup&intent=${interactionId}`,
|
||||
);
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith("/apps");
|
||||
});
|
||||
|
||||
it("steps back from the single connect screen straight to Connectors", async () => {
|
||||
@@ -3216,7 +3206,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
await openAccessAdvanced();
|
||||
await act(async () => {
|
||||
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
@@ -3254,7 +3244,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
await openAccessAdvanced();
|
||||
await act(async () => {
|
||||
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
@@ -384,9 +384,40 @@ function ActionGroup({
|
||||
onSetPermission: (id: string, next: ActionPermission) => void;
|
||||
}) {
|
||||
if (actions.length === 0) return null;
|
||||
const groupValue = (() => {
|
||||
const first = actionPermission(actions[0]!.id, enabledIds, askFirstIds);
|
||||
return actions.every((action) => actionPermission(action.id, enabledIds, askFirstIds) === first)
|
||||
? first
|
||||
: "";
|
||||
})();
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
{canConfigure ? (
|
||||
// PAP-659 C6b: set the whole group at once. Setting seventeen write
|
||||
// actions one row at a time is the reason the armed default would
|
||||
// otherwise be hard to live with; per-row overrides stay underneath.
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="sr-only">{`Set every action in ${title}`}</span>
|
||||
<select
|
||||
aria-label={`Set every action in ${title}`}
|
||||
value={groupValue}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as ActionPermission;
|
||||
for (const action of actions) onSetPermission(action.id, next);
|
||||
}}
|
||||
className="h-7 rounded-md border border-input bg-background px-2 text-xs text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||
>
|
||||
{groupValue === "" ? <option value="">Mixed</option> : null}
|
||||
{PERMISSION_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{`Set all: ${option.label}`}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{actions.map((action) => (
|
||||
<ActionRow
|
||||
@@ -455,7 +486,18 @@ function ActionRow({
|
||||
data-action-id={action.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
{/* PAP-659 C7: the gate is only as good as the classifier, so say
|
||||
what each action was classified as. A misfiled tool is then one
|
||||
glance to spot and one click to move. */}
|
||||
<span
|
||||
className="rounded-full border border-border px-1.5 py-px text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
title={`Paperclip classified this action as ${action.riskLevel}`}
|
||||
>
|
||||
{action.riskLevel}
|
||||
</span>
|
||||
</div>
|
||||
{action.description ? (
|
||||
<div className="truncate text-xs text-muted-foreground">{action.description}</div>
|
||||
) : null}
|
||||
|
||||
Reference in new issue
Block a user