|
| 1 | +// Branded HTML pages for local OAuth callback servers. |
| 2 | +// |
| 3 | +// These are served by the loopback HTTP servers that finish an OAuth exchange |
| 4 | +// (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return |
| 5 | +// a fully self-contained HTML string with no external assets, so they work |
| 6 | +// offline and drop into any transport (`res.end(...)`, Effect `response.end`, |
| 7 | +// etc.). |
| 8 | +// |
| 9 | +// The visual language mirrors the opencode app: the design tokens are a curated |
| 10 | +// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and |
| 11 | +// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`. |
| 12 | +// Keep this file in sync with those sources when the brand changes. |
| 13 | + |
| 14 | +export interface CallbackPageOptions { |
| 15 | + /** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */ |
| 16 | + provider?: string |
| 17 | + /** Attempt to close the window shortly after success. Defaults to true. */ |
| 18 | + autoClose?: boolean |
| 19 | +} |
| 20 | + |
| 21 | +export function success(options?: CallbackPageOptions) { |
| 22 | + const provider = options?.provider |
| 23 | + return renderDocument({ |
| 24 | + title: "Authorization successful", |
| 25 | + body: renderCard({ |
| 26 | + status: "success", |
| 27 | + headline: "Authorization successful", |
| 28 | + message: provider ? `opencode is now connected to ${escapeHtml(provider)}.` : "opencode is now authorized.", |
| 29 | + footnote: "You can close this window and return to opencode.", |
| 30 | + }), |
| 31 | + script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT, |
| 32 | + }) |
| 33 | +} |
| 34 | + |
| 35 | +export function error(detail: string, options?: CallbackPageOptions) { |
| 36 | + const provider = options?.provider |
| 37 | + return renderDocument({ |
| 38 | + title: "Authorization failed", |
| 39 | + body: renderCard({ |
| 40 | + status: "error", |
| 41 | + headline: "Authorization failed", |
| 42 | + message: provider |
| 43 | + ? `opencode couldn't finish connecting to ${escapeHtml(provider)}.` |
| 44 | + : "opencode couldn't complete authorization.", |
| 45 | + detail, |
| 46 | + footnote: "Close this window and try again from opencode.", |
| 47 | + }), |
| 48 | + }) |
| 49 | +} |
| 50 | + |
| 51 | +export interface BootstrapOptions { |
| 52 | + /** Same-origin path the in-browser script POSTs the parsed callback to. */ |
| 53 | + tokenPath: string |
| 54 | + provider?: string |
| 55 | +} |
| 56 | + |
| 57 | +// For flows where the credential arrives in the URL fragment (implicit grant), |
| 58 | +// the browser must relay it back to the loopback server. This renders a pending |
| 59 | +// page whose script reads the fragment, POSTs it to `tokenPath`, then resolves |
| 60 | +// to the success or error state in place. |
| 61 | +export function bootstrap(options: BootstrapOptions) { |
| 62 | + return renderDocument({ |
| 63 | + title: "Finishing sign-in", |
| 64 | + body: renderCard({ |
| 65 | + status: "pending", |
| 66 | + headline: "Finishing sign-in", |
| 67 | + message: options.provider |
| 68 | + ? `Completing your ${escapeHtml(options.provider)} authorization.` |
| 69 | + : "Completing authorization.", |
| 70 | + footnote: "You can close this window once sign-in finishes.", |
| 71 | + }), |
| 72 | + script: bootstrapScript(options), |
| 73 | + }) |
| 74 | +} |
| 75 | + |
| 76 | +export * as OauthCallbackPage from "./page" |
| 77 | + |
| 78 | +type Status = "pending" | "success" | "error" |
| 79 | + |
| 80 | +function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) { |
| 81 | + const detail = input.detail?.trim() |
| 82 | + return `<main class="card" id="oc-card" data-status="${input.status}" role="status" aria-live="polite"> |
| 83 | + <div class="brand">${WORDMARK}</div> |
| 84 | + <div class="status" aria-hidden="true"> |
| 85 | + <span class="icon icon-pending">${ICON_SPINNER}</span> |
| 86 | + <span class="icon icon-success">${ICON_CHECK}</span> |
| 87 | + <span class="icon icon-error">${ICON_CROSS}</span> |
| 88 | + </div> |
| 89 | + <h1 class="headline" id="oc-headline">${escapeHtml(input.headline)}</h1> |
| 90 | + <p class="message" id="oc-message">${input.message}</p> |
| 91 | + <pre class="detail" id="oc-detail"${detail ? "" : " hidden"}>${detail ? escapeHtml(detail) : ""}</pre> |
| 92 | + <p class="footnote" id="oc-footnote">${escapeHtml(input.footnote)}</p> |
| 93 | + </main>` |
| 94 | +} |
| 95 | + |
| 96 | +function renderDocument(input: { title: string; body: string; script?: string }) { |
| 97 | + return `<!doctype html> |
| 98 | +<html lang="en"> |
| 99 | + <head> |
| 100 | + <meta charset="utf-8" /> |
| 101 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 102 | + <meta name="robots" content="noindex" /> |
| 103 | + <title>${escapeHtml(input.title)} · opencode</title> |
| 104 | + <style>${STYLES}</style> |
| 105 | + </head> |
| 106 | + <body> |
| 107 | + ${input.body}${input.script ? `\n <script>${input.script}</script>` : ""} |
| 108 | + </body> |
| 109 | +</html>` |
| 110 | +} |
| 111 | + |
| 112 | +const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)` |
| 113 | + |
| 114 | +function bootstrapScript(options: BootstrapOptions) { |
| 115 | + return `var PROVIDER=${scriptString(options.provider ?? "")}; |
| 116 | +var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href; |
| 117 | +(function(){ |
| 118 | + 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"); |
| 119 | + 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."} |
| 120 | + 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 and return to opencode.";setTimeout(function(){try{window.close()}catch(e){}},2500)} |
| 121 | + try{ |
| 122 | + var hash=new URLSearchParams((window.location.hash||"").slice(1)); |
| 123 | + var search=new URLSearchParams(window.location.search||""); |
| 124 | + var err=hash.get("error")||search.get("error"); |
| 125 | + var errDescription=hash.get("error_description")||search.get("error_description"); |
| 126 | + 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")||""}; |
| 127 | + fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){ |
| 128 | + if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))}); |
| 129 | + if(err){fail(errDescription||err);return} |
| 130 | + ok(); |
| 131 | + }).catch(function(e){fail(String(e&&e.message?e.message:e))}); |
| 132 | + }catch(e){fail(String(e&&e.message?e.message:e))} |
| 133 | +})()` |
| 134 | +} |
| 135 | + |
| 136 | +function scriptString(value: string) { |
| 137 | + return JSON.stringify(value).replaceAll("<", "\\u003c") |
| 138 | +} |
| 139 | + |
| 140 | +function escapeHtml(value: string) { |
| 141 | + return value |
| 142 | + .replaceAll("&", "&") |
| 143 | + .replaceAll("<", "<") |
| 144 | + .replaceAll(">", ">") |
| 145 | + .replaceAll('"', """) |
| 146 | + .replaceAll("'", "'") |
| 147 | +} |
| 148 | + |
| 149 | +// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is |
| 150 | +// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a |
| 151 | +// host force a scheme without changing the default. |
| 152 | +const LIGHT_VARS = ` |
| 153 | + --oc-bg: #f8f8f8; |
| 154 | + --oc-card: #fcfcfc; |
| 155 | + --oc-text-strong: #171717; |
| 156 | + --oc-text-base: #6f6f6f; |
| 157 | + --oc-text-weak: #8f8f8f; |
| 158 | + --oc-border-weak: #e5e5e5; |
| 159 | + --oc-icon-strong: #171717; |
| 160 | + --oc-icon-base: #8f8f8f; |
| 161 | + --oc-icon-weak: #dbdbdb; |
| 162 | + --oc-success: #2dba26; |
| 163 | + --oc-error: #ed4831; |
| 164 | + --oc-detail-bg: #fff8f6; |
| 165 | + --oc-detail-border: #fdc3b7; |
| 166 | + --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);` |
| 167 | + |
| 168 | +const DARK_VARS = ` |
| 169 | + --oc-bg: #101010; |
| 170 | + --oc-card: #161616; |
| 171 | + --oc-text-strong: rgba(255,255,255,.936); |
| 172 | + --oc-text-base: rgba(255,255,255,.618); |
| 173 | + --oc-text-weak: rgba(255,255,255,.422); |
| 174 | + --oc-border-weak: #282828; |
| 175 | + --oc-icon-strong: #ededed; |
| 176 | + --oc-icon-base: #7e7e7e; |
| 177 | + --oc-icon-weak: #343434; |
| 178 | + --oc-success: #12c905; |
| 179 | + --oc-error: #fc533a; |
| 180 | + --oc-detail-bg: #28110c; |
| 181 | + --oc-detail-border: #6a1206; |
| 182 | + --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);` |
| 183 | + |
| 184 | +const STYLES = ` |
| 185 | + :root { color-scheme: light dark;${LIGHT_VARS} |
| 186 | + --oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| 187 | + --oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; |
| 188 | + } |
| 189 | + @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } } |
| 190 | + :root[data-theme="dark"] {${DARK_VARS} } |
| 191 | + :root[data-theme="light"] {${LIGHT_VARS} } |
| 192 | +
|
| 193 | + * { box-sizing: border-box; } |
| 194 | + html, body { margin: 0; height: 100%; } |
| 195 | + body { |
| 196 | + min-height: 100vh; |
| 197 | + display: grid; |
| 198 | + place-items: center; |
| 199 | + padding: 24px; |
| 200 | + background: var(--oc-bg); |
| 201 | + color: var(--oc-text-base); |
| 202 | + font-family: var(--oc-font-sans); |
| 203 | + line-height: 1.5; |
| 204 | + -webkit-font-smoothing: antialiased; |
| 205 | + text-rendering: optimizeLegibility; |
| 206 | + } |
| 207 | + .card { |
| 208 | + width: min(100%, 25rem); |
| 209 | + padding: 2.25rem 2rem 1.75rem; |
| 210 | + background: var(--oc-card); |
| 211 | + border: 1px solid var(--oc-border-weak); |
| 212 | + border-radius: 14px; |
| 213 | + box-shadow: var(--oc-shadow); |
| 214 | + text-align: center; |
| 215 | + } |
| 216 | + .brand { display: flex; justify-content: center; margin-bottom: 1.75rem; } |
| 217 | + .brand svg { height: 19px; width: auto; } |
| 218 | + .status { display: flex; justify-content: center; margin-bottom: 1.125rem; } |
| 219 | + .icon { display: none; line-height: 0; } |
| 220 | + .icon svg { display: block; } |
| 221 | + .card[data-status="pending"] .icon-pending, |
| 222 | + .card[data-status="success"] .icon-success, |
| 223 | + .card[data-status="error"] .icon-error { display: block; } |
| 224 | + .icon-success { color: var(--oc-success); } |
| 225 | + .icon-error { color: var(--oc-error); } |
| 226 | + .icon-pending { color: var(--oc-text-weak); } |
| 227 | + .headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); } |
| 228 | + .message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); } |
| 229 | + .detail { |
| 230 | + margin: 1.25rem 0 0; |
| 231 | + padding: 0.75rem 0.875rem; |
| 232 | + text-align: left; |
| 233 | + font-family: var(--oc-font-mono); |
| 234 | + font-size: 0.8125rem; |
| 235 | + line-height: 1.55; |
| 236 | + color: var(--oc-text-strong); |
| 237 | + background: var(--oc-detail-bg); |
| 238 | + border: 1px solid var(--oc-detail-border); |
| 239 | + border-radius: 8px; |
| 240 | + white-space: pre-wrap; |
| 241 | + word-break: break-word; |
| 242 | + max-height: 9.5rem; |
| 243 | + overflow: auto; |
| 244 | + } |
| 245 | + .detail[hidden] { display: none; } |
| 246 | + .footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); } |
| 247 | + .spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; } |
| 248 | + @keyframes oc-spin { to { transform: rotate(360deg); } } |
| 249 | + @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } |
| 250 | +` |
| 251 | + |
| 252 | +// opencode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo). |
| 253 | +const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="opencode" role="img"> |
| 254 | + <path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" /> |
| 255 | + <path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" /> |
| 256 | + <path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" /> |
| 257 | + <path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--oc-icon-base)" /> |
| 258 | + <path d="M84 24V30H66V24H84Z" fill="var(--oc-icon-weak)" /> |
| 259 | + <path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--oc-icon-base)" /> |
| 260 | + <path d="M108 36H96V18H108V36Z" fill="var(--oc-icon-weak)" /> |
| 261 | + <path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--oc-icon-base)" /> |
| 262 | + <path d="M144 30H126V18H144V30Z" fill="var(--oc-icon-weak)" /> |
| 263 | + <path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--oc-icon-strong)" /> |
| 264 | + <path d="M168 30H156V18H168V30Z" fill="var(--oc-icon-weak)" /> |
| 265 | + <path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--oc-icon-strong)" /> |
| 266 | + <path d="M198 30H186V18H198V30Z" fill="var(--oc-icon-weak)" /> |
| 267 | + <path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--oc-icon-strong)" /> |
| 268 | + <path d="M234 24V30H216V24H234Z" fill="var(--oc-icon-weak)" /> |
| 269 | + <path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--oc-icon-strong)" /> |
| 270 | + </svg>` |
| 271 | + |
| 272 | +const ICON_CHECK = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m8.5 12.5 2.4 2.4 4.6-5.4" /></svg>` |
| 273 | + |
| 274 | +const ICON_CROSS = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m9 9 6 6m0-6-6 6" /></svg>` |
| 275 | + |
| 276 | +const ICON_SPINNER = `<svg class="spinner" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9" opacity="0.2" /><path d="M21 12a9 9 0 0 0-9-9" /></svg>` |
0 commit comments