diff --git a/doc/assets/oauth-callback-pages/error-desktop.png b/doc/assets/oauth-callback-pages/error-desktop.png new file mode 100644 index 00000000000..cb4a9ebb1a3 Binary files /dev/null and b/doc/assets/oauth-callback-pages/error-desktop.png differ diff --git a/doc/assets/oauth-callback-pages/success-mobile.png b/doc/assets/oauth-callback-pages/success-mobile.png new file mode 100644 index 00000000000..7ba237b6293 Binary files /dev/null and b/doc/assets/oauth-callback-pages/success-mobile.png differ diff --git a/server/src/oauth-callback-page.ts b/server/src/oauth-callback-page.ts index 82c857926c4..22dd2497744 100644 --- a/server/src/oauth-callback-page.ts +++ b/server/src/oauth-callback-page.ts @@ -8,8 +8,8 @@ export function escapeCallbackHtml(value: string): string { export function oauthCallbackPage(input: { title: string; description: string; - actionHref: string; - actionLabel: string; + actionHref?: string; + actionLabel?: string; state?: "success" | "error" | "pending"; head?: string; script?: string; @@ -19,7 +19,7 @@ export function oauthCallbackPage(input: { :root{color-scheme:light dark;--bg:#faf9f6;--card:#fff;--text:#242320;--muted:#68665f;--line:#e5e3dc;--accent:#345d45} @media(prefers-color-scheme:dark){:root{--bg:#191a18;--card:#22231f;--text:#f2f1eb;--muted:#b1b2a8;--line:#3b3d35;--accent:#a5d3b4}} *{box-sizing:border-box}body{margin:0;min-height:100svh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--text);font:16px/1.6 system-ui,sans-serif}main{width:100%;max-width:460px;padding:36px;background:var(--card);border:1px solid var(--line);border-radius:16px}.brand{font-weight:650;letter-spacing:-.03em;margin-bottom:36px}.mark{display:inline-block;margin-right:8px;color:var(--accent)}.status{display:grid;place-items:center;width:44px;height:44px;border:1px solid var(--line);border-radius:50%;font-size:24px;color:var(--accent)}h1{font-size:26px;line-height:1.25;letter-spacing:-.025em;margin:20px 0 12px}p{color:var(--muted);margin:0 0 28px}a{display:inline-block;background:var(--text);color:var(--card);padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600}a:focus-visible{outline:3px solid var(--accent);outline-offset:4px}.note{font-size:13px;margin:24px 0 0}@media(max-width:480px){main{padding:24px}} -
Paperclip

${escape(input.title)}

${escape(input.description)}

${escape(input.actionLabel)}

You can close this window and return to Paperclip.

${input.script ? `` : ""}`; +
Paperclip

${escape(input.title)}

${escape(input.description)}

${input.actionHref && input.actionLabel ? `${escape(input.actionLabel)}` : ""}

You can close this window and return to Paperclip.

${input.script ? `` : ""}`; } const callbackPaths = new Set([ diff --git a/server/src/routes/tool-access-connection-intent.test.ts b/server/src/routes/tool-access-connection-intent.test.ts index fa033141e2d..b407925d74a 100644 --- a/server/src/routes/tool-access-connection-intent.test.ts +++ b/server/src/routes/tool-access-connection-intent.test.ts @@ -3,6 +3,7 @@ import { cloudConnectorEnrollmentReturnPath, cloudConnectorEnrollmentOutcomeHtml, connectionIntentOAuthOutcomeHtml, + oauthCallbackInterstitialHtml, } from "./tool-access.js"; describe("Cloud connector enrollment return path", () => { @@ -117,3 +118,10 @@ describe("inline enrollment completion", () => { expect(html).not.toContain("/apps/connect"); }); }); + + it("offers only one navigation for the single-use callback handoff", () => { + const html = oauthCallbackInterstitialHtml("/api/tools/oauth/callback?state=one&code=two"); + expect(html.match(/http-equiv="refresh"/g)).toHaveLength(1); + expect(html).not.toContain(""); + }); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 737d71b113d..019cf749881 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -224,7 +224,8 @@ export function oauthCallbackInterstitialHtml(continuePath: string): string { // two racing follow-ups would let the loser render an expired-state error. return oauthCallbackPage({ title: "Finishing connection", description: "Please wait while Paperclip finishes connecting your app.", - actionHref: continuePath, actionLabel: "Continue to Paperclip", state: "pending", + // No link: a second navigation can race the single-use automatic refresh. + state: "pending", head: ``, }); }