// Branded HTML pages for local OAuth callback servers. // // These are served by the loopback HTTP servers that finish an OAuth exchange // (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return // a fully self-contained HTML string with no external assets, so they work // offline and drop into any transport (`res.end(...)`, Effect `response.end`, // etc.). // // The visual language mirrors the OpenCode app: the design tokens are a curated // subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and // the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`. // Keep this file in sync with those sources when the brand changes. export interface CallbackPageOptions { /** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */ provider?: string /** Attempt to close the window shortly after success. Defaults to true. */ autoClose?: boolean } export function success(options?: CallbackPageOptions) { const provider = options?.provider return renderDocument({ title: "Authorization successful", body: renderCard({ status: "success", headline: "Authorization successful", message: provider ? `OpenCode is now connected to ${escapeHtml(provider)}.` : "OpenCode is now authorized.", footnote: "You can close this window.", }), script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT, }) } export function error(detail: string, options?: CallbackPageOptions) { const provider = options?.provider return renderDocument({ title: "Authorization failed", body: renderCard({ status: "error", headline: "Authorization failed", message: provider ? `OpenCode couldn't finish connecting to ${escapeHtml(provider)}.` : "OpenCode couldn't complete authorization.", detail, footnote: "Close this window and try again from OpenCode.", }), }) } export interface BootstrapOptions { /** Same-origin path the in-browser script POSTs the parsed callback to. */ tokenPath: string provider?: string } // For flows where the credential arrives in the URL fragment (implicit grant), // the browser must relay it back to the loopback server. This renders a pending // page whose script reads the fragment, POSTs it to `tokenPath`, then resolves // to the success or error state in place. export function bootstrap(options: BootstrapOptions) { return renderDocument({ title: "Finishing sign-in", body: renderCard({ status: "pending", headline: "Finishing sign-in", message: options.provider ? `Completing your ${escapeHtml(options.provider)} authorization.` : "Completing authorization.", footnote: "You can close this window once sign-in finishes.", }), script: bootstrapScript(options), }) } export * as OauthCallbackPage from "./page" type Status = "pending" | "success" | "error" function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) { const detail = input.detail?.trim() return `
${WORDMARK}

${escapeHtml(input.headline)}

${input.message}

${detail ? escapeHtml(detail) : ""}

${escapeHtml(input.footnote)}

` } function renderDocument(input: { title: string; body: string; script?: string }) { return ` ${escapeHtml(input.title)} · OpenCode ${input.body}${input.script ? `\n ` : ""} ` } const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)` function bootstrapScript(options: BootstrapOptions) { return `var PROVIDER=${scriptString(options.provider ?? "")}; var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href; (function(){ var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote"); function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("OpenCode couldn't finish connecting to "+PROVIDER+"."):"OpenCode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from OpenCode."} function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("OpenCode is now connected to "+PROVIDER+"."):"OpenCode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window.";setTimeout(function(){try{window.close()}catch(e){}},2500)} try{ var hash=new URLSearchParams((window.location.hash||"").slice(1)); var search=new URLSearchParams(window.location.search||""); var err=hash.get("error")||search.get("error"); var errDescription=hash.get("error_description")||search.get("error_description"); var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""}; fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){ if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))}); if(err){fail(errDescription||err);return} ok(); }).catch(function(e){fail(String(e&&e.message?e.message:e))}); }catch(e){fail(String(e&&e.message?e.message:e))} })()` } function scriptString(value: string) { return JSON.stringify(value).replaceAll("<", "\\u003c") } function escapeHtml(value: string) { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'") } // Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is // light; dark applies via prefers-color-scheme. The [data-theme] selectors let a // host force a scheme without changing the default. const LIGHT_VARS = ` --oc-bg: #f8f8f8; --oc-card: #fcfcfc; --oc-text-strong: #171717; --oc-text-base: #6f6f6f; --oc-text-weak: #8f8f8f; --oc-border-weak: #e5e5e5; --oc-icon-strong: #171717; --oc-icon-base: #8f8f8f; --oc-icon-weak: #dbdbdb; --oc-success: #2dba26; --oc-error: #ed4831; --oc-detail-bg: #fff8f6; --oc-detail-border: #fdc3b7; --oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);` const DARK_VARS = ` --oc-bg: #101010; --oc-card: #161616; --oc-text-strong: rgba(255,255,255,.936); --oc-text-base: rgba(255,255,255,.618); --oc-text-weak: rgba(255,255,255,.422); --oc-border-weak: #282828; --oc-icon-strong: #ededed; --oc-icon-base: #7e7e7e; --oc-icon-weak: #343434; --oc-success: #12c905; --oc-error: #fc533a; --oc-detail-bg: #28110c; --oc-detail-border: #6a1206; --oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);` const STYLES = ` :root { color-scheme: light dark;${LIGHT_VARS} --oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } } :root[data-theme="dark"] {${DARK_VARS} } :root[data-theme="light"] {${LIGHT_VARS} } * { box-sizing: border-box; } html, body { margin: 0; height: 100%; } body { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: var(--oc-bg); color: var(--oc-text-base); font-family: var(--oc-font-sans); line-height: 1.5; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } .card { width: min(100%, 28rem); padding: 2.25rem 2rem 1.75rem; background: var(--oc-card); border: 1px solid var(--oc-border-weak); border-radius: 14px; box-shadow: var(--oc-shadow); text-align: center; } .brand { display: flex; justify-content: center; margin-bottom: 1.75rem; } .brand svg { height: 19px; width: auto; } .status { display: flex; justify-content: center; margin-bottom: 1.125rem; } .icon { display: none; line-height: 0; } .icon svg { display: block; } .card[data-status="pending"] .icon-pending, .card[data-status="success"] .icon-success, .card[data-status="error"] .icon-error { display: block; } .icon-success { color: var(--oc-success); } .icon-error { color: var(--oc-error); } .icon-pending { color: var(--oc-text-weak); } .headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); } .message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); } .detail { margin: 1.25rem 0 0; padding: 0.75rem 0.875rem; text-align: left; font-family: var(--oc-font-mono); font-size: 0.8125rem; line-height: 1.55; color: var(--oc-text-strong); background: var(--oc-detail-bg); border: 1px solid var(--oc-detail-border); border-radius: 8px; white-space: pre-wrap; word-break: break-word; max-height: 9.5rem; overflow: auto; } .detail[hidden] { display: none; } .footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); } .spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; } @keyframes oc-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } ` // OpenCode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo). const WORDMARK = ` ` const ICON_CHECK = `` const ICON_CROSS = `` const ICON_SPINNER = ``