Public: anyone can read this; the crew can change it.
The connect-an-account flow leaves origins.ts for src/web/pages/origin-flow.ts #6322
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6309-the-connect-an-account-flow-leaves-origi → main 1 commit fork
3 files
changed +281
−260
src/http/server.ts +1 −2
| @@ -448,8 +448,6 @@ import { | ||
| 448 | 448 | originAppDelete, |
| 449 | 449 | originConnectToken, |
| 450 | 450 | originDisconnect, |
| 451 | originFlowPage, | |
| 452 | originFlowPoll, | |
| 453 | 451 | originMirrorSubmit, |
| 454 | 452 | originOAuthStart, |
| 455 | 453 | originReposPage, |
| @@ -457,6 +455,7 @@ import { | ||
| 457 | 455 | originsPage, |
| 458 | 456 | originTrustedHosts, |
| 459 | 457 | } from "../web/pages/origins.ts"; |
| 458 | import { originFlowPage, originFlowPoll } from "../web/pages/origin-flow.ts"; | |
| 460 | 459 | import { pushThrough } from "../origins/push-through.ts"; |
| 461 | 460 | import { notFoundPage } from "../web/chrome.ts"; |
| 462 | 461 | import { targetForShortCode } from "../origins/oauth.ts"; |
src/web/pages/origin-flow.ts added +273
| @@ -0,0 +1,273 @@ | ||
| 1 | /** | |
| 2 | * One connect in flight: the page a device grant or a code flow waits on, | |
| 3 | * and the poll it submits. An OAuth flow is a row, not a page state — the | |
| 4 | * browser can be closed, the phone can do the typing, and coming back to | |
| 5 | * `/settings/origins/flows/:id` resumes exactly where it was. | |
| 6 | * | |
| 7 | * `origins.ts` starts the flow and owns the helpers both pages share; this | |
| 8 | * file only carries it to its end. | |
| 9 | */ | |
| 10 | ||
| 11 | import { pollOAuthConnect } from "../../actions/origins.ts"; | |
| 12 | import { getFlowFor } from "../../origins/oauth.ts"; | |
| 13 | import type { Ctx } from "../../http/context.ts"; | |
| 14 | import { forgeBaseUrl } from "../../http/api.ts"; | |
| 15 | import { qrEncode, qrSvg } from "../../design/qr.ts"; | |
| 16 | import { readForm } from "../form.ts"; | |
| 17 | import { html, raw, type Html } from "../html.ts"; | |
| 18 | import { page, redirect } from "../layout.ts"; | |
| 19 | import { pollJs } from "../client.ts"; | |
| 20 | import { btn, card, icon, row } from "../ui.ts"; | |
| 21 | import { | |
| 22 | accountOnly, | |
| 23 | hostLabel, | |
| 24 | notFoundNote, | |
| 25 | shell, | |
| 26 | thenOf, | |
| 27 | type Then, | |
| 28 | } from "./origins.ts"; | |
| 29 | ||
| 30 | const thenQuery = (then: Then): string => | |
| 31 | then === undefined ? "" : "then=migrate"; | |
| 32 | ||
| 33 | /** | |
| 34 | * GET /settings/origins/flows/:id — a connect in progress. | |
| 35 | * | |
| 36 | * The device grant shows the code and where to type it; the code flow shows | |
| 37 | * one link to the instance. Both work with no JavaScript: the page carries a | |
| 38 | * "check again" form, and reloading it re-polls. With script, the same form | |
| 39 | * is submitted for you every couple of seconds. | |
| 40 | */ | |
| 41 | export function originFlowPage(ctx: Ctx): Response { | |
| 42 | const denied = accountOnly(ctx); | |
| 43 | if (denied) return denied; | |
| 44 | const flow = getFlowFor(ctx.app.db, ctx.auth.user!.id, ctx.params.id ?? ""); | |
| 45 | if (!flow) return shell(ctx, notFoundNote(), 404); | |
| 46 | const then = thenOf(ctx.url.searchParams.get("then")); | |
| 47 | ||
| 48 | if (flow.status === "done") | |
| 49 | return redirect(`${then ?? "/settings/origins"}?connected=1`); | |
| 50 | if (flow.status === "failed") | |
| 51 | return redirect( | |
| 52 | `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 120))}`, | |
| 53 | ); | |
| 54 | ||
| 55 | const pollAction = `/settings/origins/flows/${flow.id}/poll${then === undefined ? "" : `?${thenQuery(then)}`}`; | |
| 56 | const authorize = | |
| 57 | flow.flow === "code" | |
| 58 | ? authorizeLink(ctx.url.searchParams.get("to") ?? "", flow.webUrl) | |
| 59 | : undefined; | |
| 60 | const base = forgeBaseUrl(ctx.url, ctx.app.config.host); | |
| 61 | const shortLink = | |
| 62 | base === "" || flow.shortCode === "" ? "" : `${base}/go/${flow.shortCode}`; | |
| 63 | const body = | |
| 64 | flow.flow === "device" | |
| 65 | ? html`${card( | |
| 66 | html`<p class="muted small"> | |
| 67 | Type this code at | |
| 68 | <a href="${flow.verificationUri}" rel="noreferrer noopener" | |
| 69 | >${flow.verificationUri}</a | |
| 70 | >. Any device with a browser will do — this page waits. | |
| 71 | </p> | |
| 72 | <div class="bar"> | |
| 73 | <p class="device-code" id="device-code">${flow.userCode}</p> | |
| 74 | ${copyBtn("device-code", "Copy the code")} | |
| 75 | </div> | |
| 76 | ${handoff(flow.verificationUri, shortLink)}`, | |
| 77 | { | |
| 78 | head: html`${icon("github")} | |
| 79 | <h2>Enter this code</h2>`, | |
| 80 | }, | |
| 81 | )}` | |
| 82 | : html`${card( | |
| 83 | authorize === undefined | |
| 84 | ? html`<p> | |
| 85 | This connect cannot be continued from here — the authorization | |
| 86 | link was lost. Start it again from Origins. | |
| 87 | </p> | |
| 88 | <div class="bar mt"> | |
| 89 | ${btn("Back to origins", { | |
| 90 | href: "/settings/origins", | |
| 91 | variant: "primary", | |
| 92 | })} | |
| 93 | </div>` | |
| 94 | : html`<p> | |
| 95 | Continue to | |
| 96 | <span class="mono">${hostLabel(flow.webUrl)}</span> and | |
| 97 | approve the application. You will come straight back here. | |
| 98 | </p> | |
| 99 | ${handoff(authorize, shortLink, "Continue to the instance")}`, | |
| 100 | { | |
| 101 | head: html`${icon("forgejo")} | |
| 102 | <h2>Authorize on the instance</h2>`, | |
| 103 | }, | |
| 104 | )}`; | |
| 105 | ||
| 106 | const waiting = html`<form | |
| 107 | class="bar mt" | |
| 108 | method="post" | |
| 109 | action="${pollAction}" | |
| 110 | data-poll="${flow.flow === "device" | |
| 111 | ? String(Math.max(1, flow.intervalSecs)) | |
| 112 | : "0"}" | |
| 113 | > | |
| 114 | <span class="muted small" | |
| 115 | >Waiting for you to authorize | |
| 116 | ${flow.flow === "device" | |
| 117 | ? "on the other device" | |
| 118 | : "on the instance"}…</span | |
| 119 | > | |
| 120 | <span class="spacer"></span> | |
| 121 | ${btn("Check again", { size: "sm", variant: "outline", icon: "sync" })} | |
| 122 | ${btn("Cancel", { | |
| 123 | href: "/settings/origins", | |
| 124 | size: "sm", | |
| 125 | variant: "ghost", | |
| 126 | })} | |
| 127 | </form>`; | |
| 128 | ||
| 129 | return page({ | |
| 130 | ctx, | |
| 131 | title: "Connecting an origin", | |
| 132 | body: html`<div class="page-head"><h1>Connecting an origin</h1></div> | |
| 133 | <div class="narrow-wide"> | |
| 134 | ${body}${waiting} | |
| 135 | <p class="muted small mt"> | |
| 136 | No JavaScript? Refresh this page to check again — the reload re-polls. | |
| 137 | </p> | |
| 138 | </div>`, | |
| 139 | scriptJs: pollJs, | |
| 140 | }); | |
| 141 | } | |
| 142 | ||
| 143 | /** The copy affordance: an icon button that is inert without a script, and | |
| 144 | * sits beside text that stays selectable either way. */ | |
| 145 | function copyBtn(target: string, label: string): Html { | |
| 146 | return html`<button | |
| 147 | class="btn btn-icon js-only" | |
| 148 | data-size="sm" | |
| 149 | type="button" | |
| 150 | data-copy="${target}" | |
| 151 | aria-label="${label}" | |
| 152 | title="Copy" | |
| 153 | > | |
| 154 | ${icon("copy")} | |
| 155 | </button>`; | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * Getting the person to the other device: a QR code they point a camera at, | |
| 160 | * the short link they could type instead, the full URL for a browser that is | |
| 161 | * already here, and one button that opens it. | |
| 162 | * | |
| 163 | * The QR carries the short link when the forge knows its own address and the | |
| 164 | * long URL otherwise — the shorter the payload, the coarser the modules and | |
| 165 | * the further away a phone can read it. | |
| 166 | */ | |
| 167 | function handoff(url: string, shortLink: string, openLabel?: string): Html { | |
| 168 | const payload = shortLink === "" ? url : shortLink; | |
| 169 | const code = qrEncode(payload); | |
| 170 | return html`<div class="bar mt"> | |
| 171 | ${code.ok | |
| 172 | ? raw( | |
| 173 | qrSvg(code.value, { | |
| 174 | title: "Scan to open the link", | |
| 175 | scale: qrScale(code.value.modules.length), | |
| 176 | }), | |
| 177 | ) | |
| 178 | : null} | |
| 179 | </div> | |
| 180 | <ul class="list setting-list mt"> | |
| 181 | ${shortLink === "" | |
| 182 | ? null | |
| 183 | : row({ | |
| 184 | title: "Or open this on a phone:", | |
| 185 | meta: html`<span class="mono" id="flow-short">${shortLink}</span>`, | |
| 186 | trail: copyBtn("flow-short", "Copy the short link"), | |
| 187 | })} | |
| 188 | ${row({ | |
| 189 | title: "The full link:", | |
| 190 | meta: html`<span class="mono small" id="flow-url">${url}</span>`, | |
| 191 | trail: copyBtn("flow-url", "Copy the link"), | |
| 192 | })} | |
| 193 | </ul> | |
| 194 | <div class="bar mt"> | |
| 195 | ${btn(openLabel ?? "Open the verification page", { | |
| 196 | href: url, | |
| 197 | variant: "primary", | |
| 198 | icon: "external", | |
| 199 | external: true, | |
| 200 | })} | |
| 201 | </div>`; | |
| 202 | } | |
| 203 | ||
| 204 | /** | |
| 205 | * The module size that keeps the code at most 200 px square: the encoder sizes | |
| 206 | * the SVG from it, so a long URL (more modules) must not make a code wider | |
| 207 | * than a 360 px phone can hold beside the page's padding. | |
| 208 | */ | |
| 209 | function qrScale(modules: number): number { | |
| 210 | return Math.max(1, Math.min(4, Math.floor(200 / modules))); | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * The authorize URL for a code flow, checked before it becomes a link. | |
| 215 | * | |
| 216 | * It arrives on the query string because the flow row stores a *device* | |
| 217 | * flow's verification URI and nothing for a code flow. A crafted link could | |
| 218 | * therefore reach this page, so the URL is accepted only when it points at | |
| 219 | * the same instance the flow already names and carries a PKCE challenge — | |
| 220 | * the worst a forged one can do is send you to another page on the forge you | |
| 221 | * asked to connect. | |
| 222 | */ | |
| 223 | function authorizeLink(raw: string, webUrl: string): string | undefined { | |
| 224 | const url = URL.parse(raw); | |
| 225 | const instance = URL.parse(webUrl); | |
| 226 | if (!url || !instance) return undefined; | |
| 227 | if (url.origin !== instance.origin) return undefined; | |
| 228 | if (url.searchParams.get("code_challenge") === null) return undefined; | |
| 229 | return url.toString(); | |
| 230 | } | |
| 231 | ||
| 232 | /** | |
| 233 | * POST /settings/origins/flows/:id/poll — ask the origin once, on the user's | |
| 234 | * behalf. The forge does the polling, so a browser (or a phone) only ever | |
| 235 | * asks "has it happened yet?" and a token never crosses back to a surface. | |
| 236 | * | |
| 237 | * Answers JSON to a client that asked for it and a redirect to everyone | |
| 238 | * else, so the same route serves the enhancement and the plain form. | |
| 239 | */ | |
| 240 | export async function originFlowPoll(ctx: Ctx): Promise<Response> { | |
| 241 | const denied = accountOnly(ctx); | |
| 242 | if (denied) return denied; | |
| 243 | const form = await readForm(ctx, "admin"); | |
| 244 | if (form instanceof Response) return form; | |
| 245 | const polled = await pollOAuthConnect( | |
| 246 | ctx.app, | |
| 247 | ctx.auth.user, | |
| 248 | ctx.params.id ?? "", | |
| 249 | ); | |
| 250 | const wantsJson = (ctx.request.headers.get("accept") ?? "").includes( | |
| 251 | "application/json", | |
| 252 | ); | |
| 253 | if (!polled.ok) { | |
| 254 | return wantsJson | |
| 255 | ? Response.json( | |
| 256 | { status: "failed", detail: "not found" }, | |
| 257 | { status: 404 }, | |
| 258 | ) | |
| 259 | : redirect("/settings/origins?error=state"); | |
| 260 | } | |
| 261 | const { status, detail } = polled.value; | |
| 262 | const then = thenOf(ctx.url.searchParams.get("then")); | |
| 263 | if (wantsJson) return Response.json({ status, detail }); | |
| 264 | if (status === "done") | |
| 265 | return redirect(`${then ?? "/settings/origins"}?connected=1`); | |
| 266 | if (status === "failed") | |
| 267 | return redirect( | |
| 268 | `/settings/origins?error=${encodeURIComponent(detail.slice(0, 120))}`, | |
| 269 | ); | |
| 270 | return redirect( | |
| 271 | `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then)}`}`, | |
| 272 | ); | |
| 273 | } |
src/web/pages/origins.ts +7 −258
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | * a value attribute, or carried in a query string. And an OAuth flow is a |
| 14 | 14 | * row, not a page state — the browser can be closed, the phone can do the |
| 15 | 15 | * typing, and coming back to `/settings/origins/flows/:id` resumes exactly |
| 16 | * where it was. | |
| 16 | * where it was — that page and its poll live in `origin-flow.ts`. | |
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | 19 | import { |
| @@ -23,7 +23,6 @@ import { | ||
| 23 | 23 | listOriginApps, |
| 24 | 24 | listRemoteRepos, |
| 25 | 25 | listUserOrigins, |
| 26 | pollOAuthConnect, | |
| 27 | 26 | registerOriginApp, |
| 28 | 27 | startMirrorJob, |
| 29 | 28 | startOAuthConnect, |
| @@ -39,7 +38,6 @@ import { | ||
| 39 | 38 | updateTrustedHosts, |
| 40 | 39 | } from "../../origins/trusted-hosts.ts"; |
| 41 | 40 | import { reattachMessage } from "./frozen.ts"; |
| 42 | import { getFlowFor } from "../../origins/oauth.ts"; | |
| 43 | 41 | import { |
| 44 | 42 | getOriginFor, |
| 45 | 43 | listDetachedMirrors, |
| @@ -50,17 +48,15 @@ import { pinnedRepoIds } from "../../core/pins.ts"; | ||
| 50 | 48 | import { getRepo, getReposByIds } from "../../core/repos.ts"; |
| 51 | 49 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 52 | 50 | import type { Ctx } from "../../http/context.ts"; |
| 53 | import { forgeBaseUrl, oauthCallbackUri } from "../../http/api.ts"; | |
| 54 | import { qrEncode, qrSvg } from "../../design/qr.ts"; | |
| 51 | import { oauthCallbackUri } from "../../http/api.ts"; | |
| 55 | 52 | import { |
| 56 | 53 | settingsLayout, |
| 57 | 54 | signInFirst, |
| 58 | 55 | type SettingsSection, |
| 59 | 56 | } from "../chrome.ts"; |
| 60 | 57 | import { field, readForm } from "../form.ts"; |
| 61 | import { html, raw, when, type Html } from "../html.ts"; | |
| 58 | import { html, when, type Html } from "../html.ts"; | |
| 62 | 59 | import { flashTo, page, redirect } from "../layout.ts"; |
| 63 | import { pollJs } from "../client.ts"; | |
| 64 | 60 | import { browseBase, isOwnRepo } from "../scope.ts"; |
| 65 | 61 | import { |
| 66 | 62 | badge, |
| @@ -802,7 +798,7 @@ function frozenCard({ frozen, repos }: ReturnType<typeof readFrozen>): Html | nu | ||
| 802 | 798 | ); |
| 803 | 799 | } |
| 804 | 800 | |
| 805 | function shell(ctx: Ctx, body: Html, status = 200): Response { | |
| 801 | export function shell(ctx: Ctx, body: Html, status = 200): Response { | |
| 806 | 802 | return page( |
| 807 | 803 | { |
| 808 | 804 | ctx, |
| @@ -925,11 +921,9 @@ function connectMessage(error: string): string { | ||
| 925 | 921 | * Where a connect or a disconnect started, when it was not here: only |
| 926 | 922 | * `/migrate`, named by a fixed word — never a path a form can choose. |
| 927 | 923 | */ |
| 928 | type Then = "/migrate" | undefined; | |
| 929 | const thenOf = (value: string | null): Then => | |
| 924 | export type Then = "/migrate" | undefined; | |
| 925 | export const thenOf = (value: string | null): Then => | |
| 930 | 926 | value === "migrate" ? "/migrate" : undefined; |
| 931 | const thenQuery = (then: Then): string => | |
| 932 | then === undefined ? "" : "then=migrate"; | |
| 933 | 927 | |
| 934 | 928 | export async function originOAuthStart(ctx: Ctx): Promise<Response> { |
| 935 | 929 | const denied = accountOnly(ctx); |
| @@ -979,25 +973,6 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> { | ||
| 979 | 973 | return redirect(`/settings/origins/flows/${started.value.flowId}${qs}`); |
| 980 | 974 | } |
| 981 | 975 | |
| 982 | /** | |
| 983 | * The authorize URL for a code flow, checked before it becomes a link. | |
| 984 | * | |
| 985 | * It arrives on the query string because the flow row stores a *device* | |
| 986 | * flow's verification URI and nothing for a code flow. A crafted link could | |
| 987 | * therefore reach this page, so the URL is accepted only when it points at | |
| 988 | * the same instance the flow already names and carries a PKCE challenge — | |
| 989 | * the worst a forged one can do is send you to another page on the forge you | |
| 990 | * asked to connect. | |
| 991 | */ | |
| 992 | function authorizeLink(raw: string, webUrl: string): string | undefined { | |
| 993 | const url = URL.parse(raw); | |
| 994 | const instance = URL.parse(webUrl); | |
| 995 | if (!url || !instance) return undefined; | |
| 996 | if (url.origin !== instance.origin) return undefined; | |
| 997 | if (url.searchParams.get("code_challenge") === null) return undefined; | |
| 998 | return url.toString(); | |
| 999 | } | |
| 1000 | ||
| 1001 | 976 | // POST /settings/origins/apps |
| 1002 | 977 | export async function originAppCreate(ctx: Ctx): Promise<Response> { |
| 1003 | 978 | const denied = accountOnly(ctx); |
| @@ -1070,189 +1045,7 @@ export async function originAppDelete(ctx: Ctx): Promise<Response> { | ||
| 1070 | 1045 | }); |
| 1071 | 1046 | } |
| 1072 | 1047 | |
| 1073 | // ---- one connect in flight ---- | |
| 1074 | ||
| 1075 | /** | |
| 1076 | * GET /settings/origins/flows/:id — a connect in progress. | |
| 1077 | * | |
| 1078 | * The device grant shows the code and where to type it; the code flow shows | |
| 1079 | * one link to the instance. Both work with no JavaScript: the page carries a | |
| 1080 | * "check again" form, and reloading it re-polls. With script, the same form | |
| 1081 | * is submitted for you every couple of seconds. | |
| 1082 | */ | |
| 1083 | export function originFlowPage(ctx: Ctx): Response { | |
| 1084 | const denied = accountOnly(ctx); | |
| 1085 | if (denied) return denied; | |
| 1086 | const flow = getFlowFor(ctx.app.db, ctx.auth.user!.id, ctx.params.id ?? ""); | |
| 1087 | if (!flow) return shell(ctx, notFoundNote(), 404); | |
| 1088 | const then = thenOf(ctx.url.searchParams.get("then")); | |
| 1089 | ||
| 1090 | if (flow.status === "done") | |
| 1091 | return redirect(`${then ?? "/settings/origins"}?connected=1`); | |
| 1092 | if (flow.status === "failed") | |
| 1093 | return redirect( | |
| 1094 | `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 120))}`, | |
| 1095 | ); | |
| 1096 | ||
| 1097 | const pollAction = `/settings/origins/flows/${flow.id}/poll${then === undefined ? "" : `?${thenQuery(then)}`}`; | |
| 1098 | const authorize = | |
| 1099 | flow.flow === "code" | |
| 1100 | ? authorizeLink(ctx.url.searchParams.get("to") ?? "", flow.webUrl) | |
| 1101 | : undefined; | |
| 1102 | const base = forgeBaseUrl(ctx.url, ctx.app.config.host); | |
| 1103 | const shortLink = | |
| 1104 | base === "" || flow.shortCode === "" ? "" : `${base}/go/${flow.shortCode}`; | |
| 1105 | const body = | |
| 1106 | flow.flow === "device" | |
| 1107 | ? html`${card( | |
| 1108 | html`<p class="muted small"> | |
| 1109 | Type this code at | |
| 1110 | <a href="${flow.verificationUri}" rel="noreferrer noopener" | |
| 1111 | >${flow.verificationUri}</a | |
| 1112 | >. Any device with a browser will do — this page waits. | |
| 1113 | </p> | |
| 1114 | <div class="bar"> | |
| 1115 | <p class="device-code" id="device-code">${flow.userCode}</p> | |
| 1116 | ${copyBtn("device-code", "Copy the code")} | |
| 1117 | </div> | |
| 1118 | ${handoff(flow.verificationUri, shortLink)}`, | |
| 1119 | { | |
| 1120 | head: html`${icon("github")} | |
| 1121 | <h2>Enter this code</h2>`, | |
| 1122 | }, | |
| 1123 | )}` | |
| 1124 | : html`${card( | |
| 1125 | authorize === undefined | |
| 1126 | ? html`<p> | |
| 1127 | This connect cannot be continued from here — the authorization | |
| 1128 | link was lost. Start it again from Origins. | |
| 1129 | </p> | |
| 1130 | <div class="bar mt"> | |
| 1131 | ${btn("Back to origins", { | |
| 1132 | href: "/settings/origins", | |
| 1133 | variant: "primary", | |
| 1134 | })} | |
| 1135 | </div>` | |
| 1136 | : html`<p> | |
| 1137 | Continue to | |
| 1138 | <span class="mono">${hostLabel(flow.webUrl)}</span> and | |
| 1139 | approve the application. You will come straight back here. | |
| 1140 | </p> | |
| 1141 | ${handoff(authorize, shortLink, "Continue to the instance")}`, | |
| 1142 | { | |
| 1143 | head: html`${icon("forgejo")} | |
| 1144 | <h2>Authorize on the instance</h2>`, | |
| 1145 | }, | |
| 1146 | )}`; | |
| 1147 | ||
| 1148 | const waiting = html`<form | |
| 1149 | class="bar mt" | |
| 1150 | method="post" | |
| 1151 | action="${pollAction}" | |
| 1152 | data-poll="${flow.flow === "device" | |
| 1153 | ? String(Math.max(1, flow.intervalSecs)) | |
| 1154 | : "0"}" | |
| 1155 | > | |
| 1156 | <span class="muted small" | |
| 1157 | >Waiting for you to authorize | |
| 1158 | ${flow.flow === "device" | |
| 1159 | ? "on the other device" | |
| 1160 | : "on the instance"}…</span | |
| 1161 | > | |
| 1162 | <span class="spacer"></span> | |
| 1163 | ${btn("Check again", { size: "sm", variant: "outline", icon: "sync" })} | |
| 1164 | ${btn("Cancel", { | |
| 1165 | href: "/settings/origins", | |
| 1166 | size: "sm", | |
| 1167 | variant: "ghost", | |
| 1168 | })} | |
| 1169 | </form>`; | |
| 1170 | ||
| 1171 | return page({ | |
| 1172 | ctx, | |
| 1173 | title: "Connecting an origin", | |
| 1174 | body: html`<div class="page-head"><h1>Connecting an origin</h1></div> | |
| 1175 | <div class="narrow-wide"> | |
| 1176 | ${body}${waiting} | |
| 1177 | <p class="muted small mt"> | |
| 1178 | No JavaScript? Refresh this page to check again — the reload re-polls. | |
| 1179 | </p> | |
| 1180 | </div>`, | |
| 1181 | scriptJs: pollJs, | |
| 1182 | }); | |
| 1183 | } | |
| 1184 | ||
| 1185 | /** The copy affordance: an icon button that is inert without a script, and | |
| 1186 | * sits beside text that stays selectable either way. */ | |
| 1187 | function copyBtn(target: string, label: string): Html { | |
| 1188 | return html`<button | |
| 1189 | class="btn btn-icon js-only" | |
| 1190 | data-size="sm" | |
| 1191 | type="button" | |
| 1192 | data-copy="${target}" | |
| 1193 | aria-label="${label}" | |
| 1194 | title="Copy" | |
| 1195 | > | |
| 1196 | ${icon("copy")} | |
| 1197 | </button>`; | |
| 1198 | } | |
| 1199 | ||
| 1200 | /** | |
| 1201 | * Getting the person to the other device: a QR code they point a camera at, | |
| 1202 | * the short link they could type instead, the full URL for a browser that is | |
| 1203 | * already here, and one button that opens it. | |
| 1204 | * | |
| 1205 | * The QR carries the short link when the forge knows its own address and the | |
| 1206 | * long URL otherwise — the shorter the payload, the coarser the modules and | |
| 1207 | * the further away a phone can read it. | |
| 1208 | */ | |
| 1209 | function handoff(url: string, shortLink: string, openLabel?: string): Html { | |
| 1210 | const payload = shortLink === "" ? url : shortLink; | |
| 1211 | const code = qrEncode(payload); | |
| 1212 | return html`<div class="bar mt"> | |
| 1213 | ${code.ok | |
| 1214 | ? raw( | |
| 1215 | qrSvg(code.value, { | |
| 1216 | title: "Scan to open the link", | |
| 1217 | scale: qrScale(code.value.modules.length), | |
| 1218 | }), | |
| 1219 | ) | |
| 1220 | : null} | |
| 1221 | </div> | |
| 1222 | <ul class="list setting-list mt"> | |
| 1223 | ${shortLink === "" | |
| 1224 | ? null | |
| 1225 | : row({ | |
| 1226 | title: "Or open this on a phone:", | |
| 1227 | meta: html`<span class="mono" id="flow-short">${shortLink}</span>`, | |
| 1228 | trail: copyBtn("flow-short", "Copy the short link"), | |
| 1229 | })} | |
| 1230 | ${row({ | |
| 1231 | title: "The full link:", | |
| 1232 | meta: html`<span class="mono small" id="flow-url">${url}</span>`, | |
| 1233 | trail: copyBtn("flow-url", "Copy the link"), | |
| 1234 | })} | |
| 1235 | </ul> | |
| 1236 | <div class="bar mt"> | |
| 1237 | ${btn(openLabel ?? "Open the verification page", { | |
| 1238 | href: url, | |
| 1239 | variant: "primary", | |
| 1240 | icon: "external", | |
| 1241 | external: true, | |
| 1242 | })} | |
| 1243 | </div>`; | |
| 1244 | } | |
| 1245 | ||
| 1246 | /** | |
| 1247 | * The module size that keeps the code at most 200 px square: the encoder sizes | |
| 1248 | * the SVG from it, so a long URL (more modules) must not make a code wider | |
| 1249 | * than a 360 px phone can hold beside the page's padding. | |
| 1250 | */ | |
| 1251 | function qrScale(modules: number): number { | |
| 1252 | return Math.max(1, Math.min(4, Math.floor(200 / modules))); | |
| 1253 | } | |
| 1254 | ||
| 1255 | function notFoundNote(): Html { | |
| 1048 | export function notFoundNote(): Html { | |
| 1256 | 1049 | return empty({ |
| 1257 | 1050 | icon: "search", |
| 1258 | 1051 | title: "No such connect", |
| @@ -1263,50 +1056,6 @@ function notFoundNote(): Html { | ||
| 1263 | 1056 | }), |
| 1264 | 1057 | }); |
| 1265 | 1058 | } |
| 1266 | ||
| 1267 | /** | |
| 1268 | * POST /settings/origins/flows/:id/poll — ask the origin once, on the user's | |
| 1269 | * behalf. The forge does the polling, so a browser (or a phone) only ever | |
| 1270 | * asks "has it happened yet?" and a token never crosses back to a surface. | |
| 1271 | * | |
| 1272 | * Answers JSON to a client that asked for it and a redirect to everyone | |
| 1273 | * else, so the same route serves the enhancement and the plain form. | |
| 1274 | */ | |
| 1275 | export async function originFlowPoll(ctx: Ctx): Promise<Response> { | |
| 1276 | const denied = accountOnly(ctx); | |
| 1277 | if (denied) return denied; | |
| 1278 | const form = await readForm(ctx, "admin"); | |
| 1279 | if (form instanceof Response) return form; | |
| 1280 | const polled = await pollOAuthConnect( | |
| 1281 | ctx.app, | |
| 1282 | ctx.auth.user, | |
| 1283 | ctx.params.id ?? "", | |
| 1284 | ); | |
| 1285 | const wantsJson = (ctx.request.headers.get("accept") ?? "").includes( | |
| 1286 | "application/json", | |
| 1287 | ); | |
| 1288 | if (!polled.ok) { | |
| 1289 | return wantsJson | |
| 1290 | ? Response.json( | |
| 1291 | { status: "failed", detail: "not found" }, | |
| 1292 | { status: 404 }, | |
| 1293 | ) | |
| 1294 | : redirect("/settings/origins?error=state"); | |
| 1295 | } | |
| 1296 | const { status, detail } = polled.value; | |
| 1297 | const then = thenOf(ctx.url.searchParams.get("then")); | |
| 1298 | if (wantsJson) return Response.json({ status, detail }); | |
| 1299 | if (status === "done") | |
| 1300 | return redirect(`${then ?? "/settings/origins"}?connected=1`); | |
| 1301 | if (status === "failed") | |
| 1302 | return redirect( | |
| 1303 | `/settings/origins?error=${encodeURIComponent(detail.slice(0, 120))}`, | |
| 1304 | ); | |
| 1305 | return redirect( | |
| 1306 | `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then)}`}`, | |
| 1307 | ); | |
| 1308 | } | |
| 1309 | ||
| 1310 | 1059 | // ---- one origin ---- |
| 1311 | 1060 | |
| 1312 | 1061 | // POST /settings/origins/:id/sync |
new above
offline