Merge remote-tracking branch 'origin/master' into fix/runner-paid-matrix-integrity

* origin/master:
  chore(deps): bump i18next from 26.3.6 to 26.4.0 (#12267)
  Make managed Cloud OAuth handoffs invisible (#12790)
This commit is contained in:
Dotta 2026-09-03 16:41:59 -05:00
commit 46eed91836
22 changed files with 839 additions and 76 deletions

View File

@ -1103,6 +1103,12 @@ export interface ToolAppConnectionActionSummary {
*/
export type ToolOAuthClientRegistrationSource = "preconfigured" | "cimd" | "dcr" | "manual";
/** Opaque managed-Cloud exchange; clients never treat the session as a URL. */
export interface ToolOAuthHandoff {
kind: "paperclip_cloud";
session: string;
}
/**
* What an unknown remote MCP endpoint told Paperclip it needs, so the wizard can
* branch without re-probing. `manualClientRequired` means discovery succeeded but
@ -1112,6 +1118,7 @@ export type ToolOAuthClientRegistrationSource = "preconfigured" | "cimd" | "dcr"
export interface ConnectToolAppAuthChallenge {
kind: "oauth";
startUrl: string | null;
handoff?: ToolOAuthHandoff;
issuer?: string | null;
resource?: string | null;
registrationSource?: ToolOAuthClientRegistrationSource | null;
@ -1159,6 +1166,11 @@ export interface ToolOAuthStartResult {
provider: string;
authorizationUrl: string;
expiresAt: string;
/**
* Opaque Paperclip Cloud authorization handoff. The board submits this only
* to its fixed same-origin Cloud endpoint; it is never treated as a URL.
*/
handoff?: ToolOAuthHandoff;
/** Canonical authorization-server issuer this run is bound to, when discovered. */
issuer?: string | null;
/** RFC 8707 resource indicator sent with the request. */

View File

@ -1093,8 +1093,8 @@ importers:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
i18next:
specifier: ^26.3.6
version: 26.3.6(typescript@7.0.2)
specifier: ^26.4.0
version: 26.4.0(typescript@7.0.2)
lexical:
specifier: 0.48.0
version: 0.48.0(typescript@7.0.2)
@ -1118,7 +1118,7 @@ importers:
version: 19.2.8(react@19.2.8)
react-i18next:
specifier: ^17.0.11
version: 17.0.11(i18next@26.3.6(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
version: 17.0.11(i18next@26.4.0(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.18)(react@19.2.8)
@ -6426,8 +6426,8 @@ packages:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
i18next@26.3.6:
resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==}
i18next@26.4.0:
resolution: {integrity: sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==}
peerDependencies:
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
@ -13734,7 +13734,7 @@ snapshots:
human-signals@2.1.0: {}
i18next@26.3.6(typescript@7.0.2):
i18next@26.4.0(typescript@7.0.2):
optionalDependencies:
typescript: 7.0.2
@ -15101,11 +15101,11 @@ snapshots:
dependencies:
react: 19.2.8
react-i18next@17.0.11(i18next@26.3.6(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2):
react-i18next@17.0.11(i18next@26.4.0(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 4.0.1
i18next: 26.3.6(typescript@7.0.2)
i18next: 26.4.0(typescript@7.0.2)
react: 19.2.8
use-sync-external-store: 1.6.0(react@19.2.8)
optionalDependencies:

View File

@ -5040,6 +5040,14 @@ describeEmbeddedPostgres("tool access service", () => {
await grantBoardUser(db, company.id, userId, [], "owner");
const profile = "drive.read" as const;
const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile);
connector.startAuthorization = vi.fn(async ({ returnState }) => ({
authorizationUrl: `https://my.example.test/connections/confirm?session=legacy&state=${encodeURIComponent(returnState)}`,
expiresAt: new Date(Date.now() + 600_000).toISOString(),
handoff: {
kind: "paperclip_cloud" as const,
session: "cloud_session_abcdefghijklmnop",
},
}));
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
const actor = { actorType: "user" as const, actorId: userId };
const driveDefinition = getConnectableAppDefinition("google-drive")!;
@ -5062,6 +5070,10 @@ describeEmbeddedPostgres("tool access service", () => {
actor,
});
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
expect(started.handoff).toEqual({
kind: "paperclip_cloud",
session: "cloud_session_abcdefghijklmnop",
});
const app = createRouteApp(
db,
boardSessionActor(company.id, "owner", userId),

View File

@ -735,7 +735,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
returnTo: req.body.returnTo,
redirectUri: oauthRedirectUri(req),
});
res.json({ url: result.authorizationUrl });
res.json({ url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) });
});
router.post("/agents/me/connections/:connectionId/token", validate(connectionTokenRequestSchema), async (req, res) => {
@ -870,6 +870,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
...(req.body.interactionId ? { interactionId: req.body.interactionId } : {}),
});
result.auth.startUrl = start.authorizationUrl;
result.auth.handoff = start.handoff;
result.auth.issuer = start.issuer ?? result.auth.issuer ?? null;
result.auth.resource = start.resource ?? result.auth.resource ?? null;
result.auth.registrationSource = start.registrationSource ?? null;
@ -926,7 +927,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
scopes: req.body.scopes,
returnTo: req.body.returnTo,
});
res.json({ url: result.authorizationUrl });
res.json({ url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) });
},
);

View File

@ -69,6 +69,10 @@ describe("Paperclip Cloud connector", () => {
});
return Response.json({
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
handoff: {
kind: "tenant_background",
session: "broker_state_abcdefghijklmnop",
},
expiresAt: "2026-08-21T20:00:00.000Z",
}, { status: 201 });
});
@ -79,7 +83,45 @@ describe("Paperclip Cloud connector", () => {
companyId,
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
returnState: "state-1",
})).resolves.toMatchObject({ authorizationUrl: expect.stringContaining("/connections/confirm") });
})).resolves.toMatchObject({
authorizationUrl: expect.stringContaining("/connections/confirm"),
handoff: {
kind: "paperclip_cloud",
session: "broker_state_abcdefghijklmnop",
},
});
});
it("keeps legacy session responses compatible and rejects malformed handoff descriptors", async () => {
const keys = config();
const legacy = createPaperclipCloudConnector({
config: keys.config,
request: vi.fn(async () => Response.json({
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
expiresAt: "2099-08-21T20:00:00.000Z",
})) as typeof fetch,
});
await expect(legacy.startAuthorization({
subject,
companyId,
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
returnState: "state-legacy",
})).resolves.not.toHaveProperty("handoff");
const malformed = createPaperclipCloudConnector({
config: keys.config,
request: vi.fn(async () => Response.json({
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
handoff: { kind: "tenant_background", session: "not valid" },
expiresAt: "2099-08-21T20:00:00.000Z",
})) as typeof fetch,
});
await expect(malformed.startAuthorization({
subject,
companyId,
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
returnState: "state-malformed",
})).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" });
});
it("opens an instance-sealed claim and verifies its user, company, and exact scopes", async () => {

View File

@ -67,6 +67,7 @@ type SealedEnvelope = {
type ConnectorResponse = {
confirmationUrl?: unknown;
handoff?: unknown;
expiresAt?: unknown;
scopes?: unknown;
claimId?: unknown;
@ -318,7 +319,12 @@ export function createPaperclipCloudConnector(input: {
if (confirmationUrl.origin !== expectedBroker.origin || confirmationUrl.pathname !== "/connections/confirm") {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
}
return { authorizationUrl: confirmationUrl.toString(), expiresAt: response.expiresAt };
const handoff = parseCloudHandoff(response.handoff);
return {
authorizationUrl: confirmationUrl.toString(),
expiresAt: response.expiresAt,
...(handoff ? { handoff } : {}),
};
},
async claim(values: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; claimId: string; redemptionId: string }) {
const profile = values.profile ?? "gmail.draft";
@ -340,6 +346,25 @@ export function createPaperclipCloudConnector(input: {
};
}
function parseCloudHandoff(value: unknown): { kind: "paperclip_cloud"; session: string } | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid handoff", "CONNECTOR_BAD_RESPONSE");
}
const record = value as Record<string, unknown>;
const session = record.session;
if (
record.kind !== "tenant_background"
|| typeof session !== "string"
|| session.length < 16
|| session.length > 512
|| !/^[A-Za-z0-9_-]+$/.test(session)
) {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid handoff", "CONNECTOR_BAD_RESPONSE");
}
return { kind: "paperclip_cloud", session };
}
export type PaperclipCloudConnector = ReturnType<typeof createPaperclipCloudConnector>;
export type PaperclipCloudGoogleWorkspaceConnector = PaperclipCloudConnector;

View File

@ -9827,6 +9827,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
provider: "google",
authorizationUrl: session.authorizationUrl,
expiresAt: expiresAt.toISOString(),
...(session.handoff ? { handoff: session.handoff } : {}),
issuer: "https://accounts.google.com",
resource: galleryMethod.defaults?.serverUrl ?? null,
registrationSource: null,

View File

@ -57,7 +57,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"i18next": "^26.3.6",
"i18next": "^26.4.0",
"lexical": "0.48.0",
"lucide-react": "^1.38.0",
"mermaid": "^11.17.2",

View File

@ -71,6 +71,7 @@ import { canEnterAppsConnect } from "./pages/apps/app-connect-policy";
import { AppsReview } from "./pages/apps/AppsReview";
import { AppDetail } from "./pages/apps/AppDetail";
import { AppNotConnected } from "./pages/apps/AppNotConnected";
import { PaperclipCloudOAuthHandoffPage } from "./pages/apps/PaperclipCloudOAuthHandoff";
import { GatewaysList } from "./pages/apps/gateways/GatewaysList";
import { GatewayDetail } from "./pages/apps/gateways/GatewayDetail";
import { CompanySkills } from "./pages/CompanySkills";
@ -729,6 +730,7 @@ export function App() {
return (
<>
<Routes>
<Route path="oauth-handoff" element={<PaperclipCloudOAuthHandoffPage />} />
<Route path="auth" element={<AuthPage />} />
<Route path="board-claim/:token" element={<BoardClaimPage />} />
<Route path="cli-auth/:id" element={<CliAuthPage />} />

View File

@ -396,7 +396,7 @@ export const toolsApi = {
connectionId: string,
input: { subjectUserId: string; scopes?: string[]; returnTo?: string },
) =>
api.post<{ url: string }>(
api.post<{ url: string; handoff?: ToolOAuthStartResult["handoff"] }>(
`/companies/${companyId}/tools/connections/${connectionId}/start-authorization`,
input,
),

View File

@ -28,6 +28,7 @@ import type {
ToolConnectionAuthKind,
ToolConnectionCredentialSource,
ToolConnectionCreateCapabilities,
ToolOAuthStartResult,
} from "@paperclipai/shared";
import {
connectionMethodAcceptsCustomerOAuthClient,
@ -63,6 +64,7 @@ import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import { AppLogo } from "@/pages/apps/AppLogo";
import { appApplicationSourceSlug } from "@/pages/apps/app-definition-display";
@ -502,6 +504,7 @@ export function ConnectionSetupFlow({
const hydratedResumeConnectionIdRef = useRef<string | null>(null);
const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState<string | null>(null);
const oauthPopupRef = useRef<Window | null>(null);
const oauthHandoffAbortRef = useRef<AbortController | null>(null);
const [showConnectionChoice, setShowConnectionChoice] = useState(
existingConnections.length > 0 && Boolean(onUseExisting),
);
@ -534,6 +537,38 @@ export function ConnectionSetupFlow({
popup.focus();
}, [host, onPhaseChange]);
const prepareAndOpenOAuth = useCallback(async (
start: Pick<ToolOAuthStartResult, "authorizationUrl" | "handoff">,
) => {
oauthHandoffAbortRef.current?.abort();
const controller = new AbortController();
oauthHandoffAbortRef.current = controller;
try {
const target = await prepareOAuthNavigation(start, { signal: controller.signal });
if (target.kind === "reauthentication") {
const destination = host === "dialog" ? oauthPopupRef.current : window;
if (!destination || destination.closed || !start.handoff) {
throw new Error("Paperclip couldnt preserve this sign-in while refreshing your account.");
}
savePendingCloudHandoff(start.handoff.session, destination.sessionStorage);
setOAuthPhase("starting");
} else {
setAuthorizationHost(target.host);
setOAuthPhase("redirecting");
}
openAuthorization(target.url);
} catch (error) {
if (controller.signal.aborted) return;
setOAuthPhase("error");
setOAuthError(error instanceof Error ? error.message : "Paperclip couldnt start secure sign-in. Try again.");
onPhaseChange?.("needs_retry");
} finally {
if (oauthHandoffAbortRef.current === controller) oauthHandoffAbortRef.current = null;
}
}, [host, onPhaseChange, openAuthorization]);
useEffect(() => () => oauthHandoffAbortRef.current?.abort(), []);
useEffect(() => {
if (host !== "dialog" || !connectionIntentId) return;
const receiveOAuthOutcome = (event: MessageEvent) => {
@ -804,20 +839,7 @@ export function ConnectionSetupFlow({
asCurrentUser: connection.credentialPolicy === "per_user",
...(connectionIntentId ? { interactionId: connectionIntentId } : {}),
}),
onSuccess: ({ authorizationUrl }) => {
// The endpoint chose this address, so it is checked here too — this is the
// line where an unsafe scheme would actually run (PAP-17099).
const target = resolveAuthorizationTarget(authorizationUrl);
if (!target.ok) {
setOAuthPhase("error");
setOAuthError(target.message);
onPhaseChange?.("needs_retry");
return;
}
setAuthorizationHost(target.host);
setOAuthPhase("redirecting");
openAuthorization(target.url);
},
onSuccess: (start) => void prepareAndOpenOAuth(start),
onError: (error) => {
const details = error instanceof ApiError && error.body && typeof error.body === "object"
? (error.body as { details?: { code?: unknown } }).details
@ -963,18 +985,11 @@ export function ConnectionSetupFlow({
startOAuth(result.connection);
return;
}
const target = resolveAuthorizationTarget(startUrl);
if (!target.ok) {
setGenericOAuthPending(true);
setOAuthPhase("error");
setOAuthError(target.message);
onPhaseChange?.("needs_retry");
return;
}
setAuthorizationHost(target.host);
setOAuthPhase("redirecting");
setGenericOAuthPending(true);
openAuthorization(target.url);
void prepareAndOpenOAuth({
authorizationUrl: startUrl,
handoff: result.auth.handoff,
});
return;
}
setLinkGuidance(null);
@ -1550,11 +1565,15 @@ export function ConnectionSetupFlow({
}
}}
onBack={() => {
oauthHandoffAbortRef.current?.abort();
setOAuthPhase("entry");
setOAuthError(null);
setAppStep("access");
}}
onCancel={onCancel ?? (() => navigate("/apps"))}
onCancel={() => {
oauthHandoffAbortRef.current?.abort();
(onCancel ?? (() => navigate("/apps")))();
}}
/>
);
}
@ -1585,11 +1604,15 @@ export function ConnectionSetupFlow({
setOAuthPhase("entry");
}}
onBack={() => {
oauthHandoffAbortRef.current?.abort();
setGenericOAuthPending(false);
setOAuthPhase("entry");
setOAuthError(null);
}}
onCancel={onCancel ?? (() => navigate("/apps"))}
onCancel={() => {
oauthHandoffAbortRef.current?.abort();
(onCancel ?? (() => navigate("/apps")))();
}}
/>
);
}

View File

@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearPendingCloudHandoff,
OAuthHandoffError,
prepareOAuthNavigation,
readPendingCloudHandoff,
savePendingCloudHandoff,
} from "./oauthHandoff";
const SESSION = "cloud_session_abcdefghijklmnop";
afterEach(() => {
vi.restoreAllMocks();
window.sessionStorage.clear();
});
describe("Paperclip Cloud OAuth handoff", () => {
it("uses the fixed same-origin endpoint and never navigates to the legacy confirmation URL", async () => {
const request = vi.fn(async () => Response.json({
authorizationUrl: "https://provider.example.test/authorize?state=one",
}));
const target = await prepareOAuthNavigation({
authorizationUrl: "https://my.example.test/connections/confirm?session=legacy",
handoff: { kind: "paperclip_cloud", session: SESSION },
}, { request: request as typeof fetch });
expect(request).toHaveBeenCalledWith("/cloud/connections/handoff", expect.objectContaining({
method: "POST",
credentials: "include",
body: JSON.stringify({ session: SESSION }),
}));
expect(target).toMatchObject({ kind: "authorization", host: "provider.example.test" });
expect(target.url).not.toContain("/connections/confirm");
});
it("retries one transient failure with the identical opaque session", async () => {
const request = vi.fn()
.mockResolvedValueOnce(new Response(null, { status: 503 }))
.mockResolvedValueOnce(Response.json({ authorizationUrl: "https://provider.example.test/authorize" }));
await prepareOAuthNavigation({
authorizationUrl: "https://my.example.test/connections/confirm",
handoff: { kind: "paperclip_cloud", session: SESSION },
}, { request });
expect(request).toHaveBeenCalledTimes(2);
expect(request.mock.calls[0]?.[1]?.body).toBe(request.mock.calls[1]?.[1]?.body);
});
it("accepts only the fixed same-origin reauthentication route", async () => {
const request = vi.fn(async () => Response.json({
error: "RECENT_LOGIN_REQUIRED",
reauthenticationUrl: `${window.location.origin}/cloud/connections/reauth?session=${SESSION}`,
}, { status: 401 }));
await expect(prepareOAuthNavigation({
authorizationUrl: "https://my.example.test/connections/confirm",
handoff: { kind: "paperclip_cloud", session: SESSION },
}, { request: request as typeof fetch })).resolves.toMatchObject({ kind: "reauthentication" });
request.mockResolvedValueOnce(Response.json({
error: "RECENT_LOGIN_REQUIRED",
reauthenticationUrl: `https://evil.example/cloud/connections/reauth?session=${SESSION}`,
}, { status: 401 }));
await expect(prepareOAuthNavigation({
authorizationUrl: "https://my.example.test/connections/confirm",
handoff: { kind: "paperclip_cloud", session: SESSION },
}, { request: request as typeof fetch })).rejects.toMatchObject({ code: "forbidden" });
});
it("rejects malformed descriptors without making a request", async () => {
const request = vi.fn();
await expect(prepareOAuthNavigation({
authorizationUrl: "https://provider.example.test/authorize",
handoff: { kind: "paperclip_cloud", session: "bad session" },
}, { request: request as typeof fetch })).rejects.toBeInstanceOf(OAuthHandoffError);
expect(request).not.toHaveBeenCalled();
});
it("keeps the opaque handoff in per-tab storage for reauthentication resume", () => {
savePendingCloudHandoff(SESSION);
expect(readPendingCloudHandoff()).toEqual({ kind: "paperclip_cloud", session: SESSION });
clearPendingCloudHandoff();
expect(readPendingCloudHandoff()).toBeNull();
});
it("keeps direct and legacy OAuth on the validated authorization URL", async () => {
await expect(prepareOAuthNavigation({
authorizationUrl: "https://provider.example.test/authorize",
})).resolves.toMatchObject({ kind: "authorization", host: "provider.example.test" });
});
});

180
ui/src/lib/oauthHandoff.ts Normal file
View File

@ -0,0 +1,180 @@
import type { ToolOAuthStartResult } from "@paperclipai/shared";
import { resolveAuthorizationTarget } from "./authorizationUrl";
const CLOUD_HANDOFF_PATH = "/cloud/connections/handoff";
const CLOUD_REAUTH_PATH = "/cloud/connections/reauth";
const PENDING_HANDOFF_KEY = "paperclip.cloud-oauth-handoff.v1";
export type PreparedOAuthNavigation = {
kind: "authorization" | "reauthentication";
url: string;
host: string;
};
type PendingCloudHandoff = {
version: 1;
session: string;
savedAt: number;
};
export class OAuthHandoffError extends Error {
constructor(
message: string,
readonly code:
| "invalid_handoff"
| "expired"
| "forbidden"
| "unavailable",
) {
super(message);
this.name = "OAuthHandoffError";
}
}
function parseHandoff(value: unknown): { kind: "paperclip_cloud"; session: string } | null {
if (value === undefined) return null;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new OAuthHandoffError("Paperclip Cloud returned an invalid sign-in handoff.", "invalid_handoff");
}
const handoff = value as Record<string, unknown>;
if (
handoff.kind !== "paperclip_cloud"
|| typeof handoff.session !== "string"
|| handoff.session.length < 16
|| handoff.session.length > 512
|| !/^[A-Za-z0-9_-]+$/.test(handoff.session)
) {
throw new OAuthHandoffError("Paperclip Cloud returned an invalid sign-in handoff.", "invalid_handoff");
}
return { kind: "paperclip_cloud", session: handoff.session };
}
function handoffFailure(status: number, code: unknown): OAuthHandoffError {
if (status === 404 || code === "SESSION_NOT_AVAILABLE") {
return new OAuthHandoffError("This sign-in expired. Start the connection again.", "expired");
}
if (status === 401 || status === 403) {
return new OAuthHandoffError("Paperclip Cloud could not authorize this connection for your account.", "forbidden");
}
return new OAuthHandoffError("Paperclip Cloud couldnt prepare secure sign-in. Try again.", "unavailable");
}
async function postCloudHandoff(
session: string,
options: { signal?: AbortSignal; request?: typeof fetch },
): Promise<Response> {
const request = options.request ?? fetch;
let response: Response | undefined;
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
response = await request(CLOUD_HANDOFF_PATH, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
credentials: "include",
body: JSON.stringify({ session }),
signal: options.signal,
});
if (response.status < 500 || attempt === 1) return response;
} catch (error) {
if (options.signal?.aborted || (error instanceof DOMException && error.name === "AbortError")) throw error;
lastError = error;
if (attempt === 1) break;
}
}
if (response) return response;
throw new OAuthHandoffError(
lastError instanceof Error ? lastError.message : "Paperclip Cloud couldnt prepare secure sign-in. Try again.",
"unavailable",
);
}
function exactReauthenticationTarget(value: unknown, session: string): PreparedOAuthNavigation | null {
if (typeof value !== "string" || typeof window === "undefined") return null;
try {
const url = new URL(value, window.location.origin);
if (
url.origin !== window.location.origin
|| url.pathname !== CLOUD_REAUTH_PATH
|| url.username
|| url.password
|| url.hash
|| url.searchParams.size !== 1
|| url.searchParams.get("session") !== session
) return null;
return { kind: "reauthentication", url: url.toString(), host: url.host };
} catch {
return null;
}
}
/**
* Resolve a start response into the next browser navigation.
*
* Managed Cloud sessions are exchanged only through the fixed same-origin
* endpoint. Legacy, self-hosted, and direct provider OAuth keep using the
* server-supplied authorization URL after the existing URL safety gate.
*/
export async function prepareOAuthNavigation(
start: Pick<ToolOAuthStartResult, "authorizationUrl" | "handoff">,
options: { signal?: AbortSignal; request?: typeof fetch } = {},
): Promise<PreparedOAuthNavigation> {
const handoff = parseHandoff(start.handoff);
if (!handoff) {
const target = resolveAuthorizationTarget(start.authorizationUrl);
if (!target.ok) throw new OAuthHandoffError(target.message, "invalid_handoff");
return { kind: "authorization", url: target.url, host: target.host };
}
const response = await postCloudHandoff(handoff.session, options);
const body = await response.json().catch(() => null) as Record<string, unknown> | null;
if (!response.ok) {
if (body?.error === "RECENT_LOGIN_REQUIRED") {
const reauthentication = exactReauthenticationTarget(body.reauthenticationUrl, handoff.session);
if (reauthentication) return reauthentication;
}
throw handoffFailure(response.status, body?.error);
}
const authorization = resolveAuthorizationTarget(
typeof body?.authorizationUrl === "string" ? body.authorizationUrl : undefined,
);
if (!authorization.ok) {
throw new OAuthHandoffError("Paperclip Cloud returned an invalid provider sign-in address.", "invalid_handoff");
}
return { kind: "authorization", url: authorization.url, host: authorization.host };
}
export function savePendingCloudHandoff(
session: string,
storage: Pick<Storage, "setItem"> = window.sessionStorage,
): void {
const handoff = parseHandoff({ kind: "paperclip_cloud", session });
if (!handoff) throw new OAuthHandoffError("Paperclip Cloud returned an invalid sign-in handoff.", "invalid_handoff");
const pending: PendingCloudHandoff = { version: 1, session: handoff.session, savedAt: Date.now() };
storage.setItem(PENDING_HANDOFF_KEY, JSON.stringify(pending));
}
export function readPendingCloudHandoff(
storage: Pick<Storage, "getItem" | "removeItem"> = window.sessionStorage,
): { kind: "paperclip_cloud"; session: string } | null {
const raw = storage.getItem(PENDING_HANDOFF_KEY);
if (!raw) return null;
try {
const pending = JSON.parse(raw) as Partial<PendingCloudHandoff>;
if (
pending.version !== 1
|| typeof pending.savedAt !== "number"
|| Date.now() - pending.savedAt > 15 * 60_000
) throw new Error("expired");
return parseHandoff({ kind: "paperclip_cloud", session: pending.session });
} catch {
storage.removeItem(PENDING_HANDOFF_KEY);
return null;
}
}
export function clearPendingCloudHandoff(
storage: Pick<Storage, "removeItem"> = window.sessionStorage,
): void {
storage.removeItem(PENDING_HANDOFF_KEY);
}

View File

@ -401,6 +401,7 @@ describe("AppDetail", () => {
afterEach(() => {
flushSync(() => root?.unmount());
container.remove();
vi.restoreAllMocks();
vi.clearAllMocks();
});
@ -1504,6 +1505,36 @@ describe("AppDetail", () => {
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://accounts.example.test/authorize");
});
it("keeps personal managed authorization in the tenant until the provider is ready", async () => {
const session = "personal_background_session_1234";
const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({
authorizationUrl: "https://provider.example.test/authorize?state=personal",
}));
mockParams.tab = "setup";
getConnectionMock.mockResolvedValue(perUserConnection());
startPersonalAuthorizationMock.mockResolvedValue({
url: "https://my.paperclip.app/connections/confirm?session=legacy",
handoff: { kind: "paperclip_cloud", session },
});
await renderAppDetail();
await act(async () => {
findButton("Connect as me")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(request).toHaveBeenCalledWith("/cloud/connections/handoff", expect.objectContaining({
method: "POST",
body: JSON.stringify({ session }),
}));
await vi.waitFor(() => {
expect(navigateTopLevelMock).toHaveBeenCalledWith(
"https://provider.example.test/authorize?state=personal",
);
});
expect(navigateTopLevelMock).not.toHaveBeenCalledWith(expect.stringContaining("/connections/confirm"));
});
it("opens Permissions from app access instead of personal identity delegations", async () => {
mockParams.tab = "setup";
getConnectionMock.mockResolvedValue(perUserConnection());

View File

@ -23,8 +23,8 @@ import { accessApi } from "@/api/access";
import { authApi } from "@/api/auth";
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "@/lib/company-members";
import { installPayload, installStateFrom, type InstallState } from "@/lib/tool-installs";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
@ -372,15 +372,20 @@ export function AppDetail() {
const startOAuth = useMutation({
mutationFn: () => toolsApi.startOAuth(connectionId),
onSuccess: ({ authorizationUrl }) => {
// Checked again at the navigation boundary (PAP-17099): the address came
// from the remote server, and this is where an unsafe scheme would run.
const target = resolveAuthorizationTarget(authorizationUrl);
if (!target.ok) {
pushToast({ title: "Couldn't start sign-in", body: target.message, tone: "error" });
return;
onSuccess: async (start) => {
try {
const target = await prepareOAuthNavigation(start);
if (target.kind === "reauthentication" && start.handoff) {
savePendingCloudHandoff(start.handoff.session);
}
navigateTopLevel(target.url);
} catch (error) {
pushToast({
title: "Couldn't start sign-in",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
}
navigateTopLevel(target.url);
},
onError: (error) =>
pushToast({
@ -409,13 +414,20 @@ export function AppDetail() {
returnTo: appTabHref(connectionId, "setup"),
});
},
onSuccess: ({ url }) => {
const target = resolveAuthorizationTarget(url);
if (!target.ok) {
pushToast({ title: "Couldn't start sign-in", body: target.message, tone: "error" });
return;
onSuccess: async ({ url, handoff }) => {
try {
const target = await prepareOAuthNavigation({ authorizationUrl: url, handoff });
if (target.kind === "reauthentication" && handoff) {
savePendingCloudHandoff(handoff.session);
}
navigateTopLevel(target.url);
} catch (error) {
pushToast({
title: "Couldn't start sign-in",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
}
navigateTopLevel(target.url);
},
onError: (error) =>
pushToast({

View File

@ -254,6 +254,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
}
document.body.removeChild(container);
document.body.innerHTML = "";
vi.restoreAllMocks();
vi.clearAllMocks();
});
@ -2396,6 +2397,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
}
document.body.removeChild(container);
document.body.innerHTML = "";
vi.restoreAllMocks();
vi.clearAllMocks();
});
@ -2620,6 +2622,46 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
expect(container.textContent).toContain("auth.example.test");
});
it("exchanges a managed connect response in the tenant without opening Cloud confirmation", async () => {
const session = "managed_background_session_1234";
const request = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) =>
input === "/cloud/connections/handoff"
? Response.json({ authorizationUrl: "https://provider.example.test/authorize?state=managed" })
: new Response(null, { status: 404 }));
connectAppMock.mockResolvedValue({
connectionId: "conn-1",
application: { id: "app-1", name: "mcp.example.test" },
connection: { id: "conn-1", credentialPolicy: "shared", status: "draft" },
actions: { readOnly: [], canMakeChanges: [] },
catalog: [],
suggestedDefaults: {},
auth: {
kind: "oauth",
startUrl: "https://my.paperclip.app/connections/confirm?session=legacy",
handoff: { kind: "paperclip_cloud", session },
},
});
await render();
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
await act(async () => {
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
await flushReact();
expect(request).toHaveBeenCalledWith("/cloud/connections/handoff", expect.objectContaining({
method: "POST",
body: JSON.stringify({ session }),
}));
await vi.waitFor(() => {
expect(navigateTopLevelMock).toHaveBeenCalledWith(
"https://provider.example.test/authorize?state=managed",
);
});
expect(navigateTopLevelMock).not.toHaveBeenCalledWith(expect.stringContaining("/connections/confirm"));
});
/**
* PAP-17099 a generic MCP server picks its own authorization endpoint, and
* `window.location.assign` is where an unsafe scheme would actually execute.

View File

@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { PaperclipCloudOAuthHandoffPage } from "./PaperclipCloudOAuthHandoff";
const navigateTopLevel = vi.hoisted(() => vi.fn());
const SESSION = "cloud_session_abcdefghijklmnop";
vi.mock("@/lib/browserNavigation", () => ({
navigateTopLevel: (url: string) => navigateTopLevel(url),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
window.sessionStorage.clear();
navigateTopLevel.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
});
describe("PaperclipCloudOAuthHandoffPage", () => {
it("keeps a tenant loading state visible until the provider URL is ready", async () => {
savePendingCloudHandoff(SESSION);
let complete: ((response: Response) => void) | undefined;
vi.spyOn(globalThis, "fetch").mockImplementation(() => new Promise((resolve) => {
complete = resolve;
}));
await act(async () => root.render(<PaperclipCloudOAuthHandoffPage />));
expect(container.textContent).toContain("Preparing secure sign-in");
expect(navigateTopLevel).not.toHaveBeenCalled();
await act(async () => {
complete?.(Response.json({ authorizationUrl: "https://provider.example.test/authorize" }));
});
await flushReact();
expect(navigateTopLevel).toHaveBeenCalledWith("https://provider.example.test/authorize");
expect(window.sessionStorage.length).toBe(0);
});
it("keeps terminal handoff failures in Paperclip instead of opening confirmation", async () => {
savePendingCloudHandoff(SESSION);
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({
error: "SESSION_NOT_AVAILABLE",
}, { status: 404 }));
await act(async () => root.render(<PaperclipCloudOAuthHandoffPage />));
await flushReact();
expect(container.textContent).toContain("Sign-in couldnt continue");
expect(container.textContent).toContain("This sign-in expired. Start the connection again.");
expect(navigateTopLevel).not.toHaveBeenCalled();
});
it("does not loop when recent-login recovery remains stale", async () => {
savePendingCloudHandoff(SESSION);
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({
error: "RECENT_LOGIN_REQUIRED",
reauthenticationUrl: `${window.location.origin}/cloud/connections/reauth?session=${SESSION}`,
}, { status: 401 }));
await act(async () => root.render(<PaperclipCloudOAuthHandoffPage />));
await flushReact();
expect(container.textContent).toContain("Paperclip couldnt refresh this sign-in. Try again to continue.");
expect(navigateTopLevel).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from "react";
import { Link2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { navigateTopLevel } from "@/lib/browserNavigation";
import {
clearPendingCloudHandoff,
prepareOAuthNavigation,
readPendingCloudHandoff,
} from "@/lib/oauthHandoff";
export type ManagedOAuthHandoffPhase = "loading" | "reauthenticating" | "error";
export function ManagedOAuthHandoffState({
phase,
error,
onRetry,
onCancel,
}: {
phase: ManagedOAuthHandoffPhase;
error?: string | null;
onRetry: () => void;
onCancel: () => void;
}) {
const failed = phase === "error";
return (
<main className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="flex max-w-lg items-start gap-3">
<span className="mt-1 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
{failed ? (
<Link2 className="h-5 w-5 text-destructive" />
) : (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
)}
</span>
<div className="min-w-0">
<h1 className="text-xl font-bold tracking-tight">
{failed ? "Sign-in couldnt continue" : "Preparing secure sign-in"}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{failed
? error ?? "Paperclip couldnt prepare the provider sign-in. Try again."
: phase === "reauthenticating"
? "Your Paperclip sign-in is being refreshed."
: "Paperclip is opening the provider securely."}
</p>
{failed ? (
<div className="mt-6 flex items-center gap-2">
<Button type="button" onClick={onRetry}>Try again</Button>
<Button type="button" variant="ghost" onClick={onCancel}>Return to Paperclip</Button>
</div>
) : null}
</div>
</div>
</main>
);
}
/** Fixed tenant landing used only after Paperclip Cloud refreshes login. */
export function PaperclipCloudOAuthHandoffPage() {
const [phase, setPhase] = useState<ManagedOAuthHandoffPhase>("loading");
const [error, setError] = useState<string | null>(null);
const resume = useCallback(async () => {
const handoff = readPendingCloudHandoff();
if (!handoff) {
setPhase("error");
setError("This sign-in expired. Return to Paperclip and start the connection again.");
return;
}
setPhase("loading");
setError(null);
try {
const target = await prepareOAuthNavigation({ authorizationUrl: "", handoff });
if (target.kind === "reauthentication") {
setPhase("error");
setError("Paperclip couldnt refresh this sign-in. Try again to continue.");
return;
}
clearPendingCloudHandoff();
navigateTopLevel(target.url);
} catch (caught) {
setPhase("error");
setError(caught instanceof Error ? caught.message : "Paperclip couldnt prepare secure sign-in.");
}
}, []);
useEffect(() => {
void resume();
}, [resume]);
return (
<ManagedOAuthHandoffState
phase={phase}
error={error}
onRetry={() => void resume()}
onCancel={() => {
clearPendingCloudHandoff();
navigateTopLevel("/");
}}
/>
);
}

View File

@ -19,8 +19,8 @@ import { Input } from "@/components/ui/input";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { useToast } from "@/context/ToastContext";
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { cn } from "@/lib/utils";
import type { AppDetailSectionProps } from "./types";
import { RevokeGrantDialog } from "./IdentitiesSection";
@ -158,15 +158,20 @@ export function ReconnectCard({
mutationFn: () => connection.credentialPolicy === "per_user"
? toolsApi.startOAuth(connection.id, { asCurrentUser: true })
: toolsApi.startOAuth(connection.id),
onSuccess: ({ authorizationUrl }) => {
// Reconnect navigates to the same discovered address a fresh connect does,
// so it goes through the same gate (PAP-17099).
const target = resolveAuthorizationTarget(authorizationUrl);
if (!target.ok) {
pushToast({ title: "Couldnt start sign-in", body: target.message, tone: "error" });
return;
onSuccess: async (start) => {
try {
const target = await prepareOAuthNavigation(start);
if (target.kind === "reauthentication" && start.handoff) {
savePendingCloudHandoff(start.handoff.session);
}
navigateTopLevel(target.url);
} catch (error) {
pushToast({
title: "Couldnt start sign-in",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
}
navigateTopLevel(target.url);
},
onError: (error) =>
pushToast({

View File

@ -181,6 +181,7 @@ describe("PasteConfigTab — discoverability copy (PAP-11091)", () => {
afterEach(() => {
document.body.removeChild(container);
document.body.innerHTML = "";
vi.restoreAllMocks();
vi.clearAllMocks();
});
@ -222,6 +223,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
afterEach(() => {
document.body.removeChild(container);
document.body.innerHTML = "";
vi.restoreAllMocks();
vi.clearAllMocks();
});
@ -374,6 +376,37 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
expect(container.textContent).not.toContain("Review actions for notion");
});
it("uses the background Cloud handoff returned with an imported OAuth connection", async () => {
const session = "imported_background_session_1234";
const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({
authorizationUrl: "https://provider.example.test/authorize?state=imported",
}));
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
const result = oauthConnectResult("https://my.paperclip.app/connections/confirm?session=legacy");
result.auth = {
...result.auth!,
handoff: { kind: "paperclip_cloud", session },
};
toolsApiMock.connectApp.mockResolvedValue(result);
await act(async () => {
buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(request).toHaveBeenCalledWith("/cloud/connections/handoff", expect.objectContaining({
method: "POST",
body: JSON.stringify({ session }),
}));
await vi.waitFor(() => {
expect(navigateTopLevelMock).toHaveBeenCalledWith(
"https://provider.example.test/authorize?state=imported",
);
});
expect(navigateTopLevelMock).not.toHaveBeenCalledWith(expect.stringContaining("/connections/confirm"));
});
it("rejects an unsafe start URL and retries OAuth on the same connection", async () => {
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)"));

View File

@ -7,14 +7,15 @@ import type {
McpJsonImportDraft,
McpJsonImportPreview,
ToolAppConnectionActionSummary,
ToolOAuthStartResult,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { toolsApi } from "@/api/tools";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { useNavigate } from "@/lib/router";
import {
OAuthConnectStateScreen,
@ -122,21 +123,27 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
},
});
const openAuthorizationPage = (authorizationUrl: string) => {
const target = resolveAuthorizationTarget(authorizationUrl);
if (!target.ok) {
const openAuthorizationPage = async (
authorizationUrl: string,
handoff?: ToolOAuthStartResult["handoff"],
) => {
try {
const target = await prepareOAuthNavigation({ authorizationUrl, handoff });
if (target.kind === "reauthentication" && handoff) {
savePendingCloudHandoff(handoff.session);
}
setAuthorizationHost(target.host);
setOAuthPhase(target.kind === "authorization" ? "redirecting" : "starting");
navigateTopLevel(target.url);
} catch (error) {
setOAuthPhase("error");
setOAuthError(target.message);
return;
setOAuthError(error instanceof Error ? error.message : "Paperclip couldnt start secure sign-in. Try again.");
}
setAuthorizationHost(target.host);
setOAuthPhase("redirecting");
navigateTopLevel(target.url);
};
const oauthStartMutation = useMutation({
mutationFn: (connectionId: string) => toolsApi.startOAuth(connectionId),
onSuccess: ({ authorizationUrl }) => openAuthorizationPage(authorizationUrl),
onSuccess: (start) => void openAuthorizationPage(start.authorizationUrl, start.handoff),
onError: (error) => {
setOAuthPhase("error");
setOAuthError(
@ -172,7 +179,7 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
}
const startUrl = result.auth.startUrl?.trim();
if (startUrl) {
openAuthorizationPage(startUrl);
void openAuthorizationPage(startUrl, result.auth.handoff);
} else {
setOAuthPhase("starting");
oauthStartMutation.mutate(result.connectionId);

View File

@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ManagedOAuthHandoffState } from "@/pages/apps/PaperclipCloudOAuthHandoff";
const meta: Meta<typeof ManagedOAuthHandoffState> = {
title: "Apps/Managed Cloud OAuth handoff",
component: ManagedOAuthHandoffState,
parameters: { layout: "fullscreen" },
args: {
onRetry: () => undefined,
onCancel: () => undefined,
},
};
export default meta;
type Story = StoryObj<typeof ManagedOAuthHandoffState>;
export const PreparingProvider: Story = {
args: { phase: "loading" },
};
export const ResumingAfterReauthentication: Story = {
args: { phase: "reauthenticating" },
};
export const RetryAfterInfrastructureFailure: Story = {
args: {
phase: "error",
error: "Paperclip Cloud couldnt prepare secure sign-in. Try again.",
},
};
export const TerminalExpiredSession: Story = {
args: {
phase: "error",
error: "This sign-in expired. Return to Paperclip and start the connection again.",
},
};