Public: anyone can read this; the crew can change it.
The origins page's handoff and connect path draw §5's card, row and btn, and .handoff, .handoff-links, .connect-more, .connect-path leave the markup #4211
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4154-the-origins-page-s-handoff-and-connect-p → main 3 commits fork 3 threads · 3 unresolved
2 files
changed +164
−69
src/web/pages/origins.ts +100 −69
| @@ -404,9 +404,7 @@ function githubSection(configured: boolean, callback: string): Html { | ||
| 404 | 404 | if (!configured) { |
| 405 | 405 | const back = |
| 406 | 406 | callback === "" ? "<your forge>/settings/origins/callback" : callback; |
| 407 | return html`<div class="connect-path"> | |
| 408 | <h3>${icon("github")}GitHub, one click</h3> | |
| 409 | <p class="muted small"> | |
| 407 | return html`<p class="muted small"> | |
| 410 | 408 | Needs a one-time OAuth app setup: no GitHub application is registered |
| 411 | 409 | for this forge yet, so the device flow has no client id to use. Three |
| 412 | 410 | steps, once, for every user of this forge: |
| @@ -434,20 +432,16 @@ function githubSection(configured: boolean, callback: string): Html { | ||
| 434 | 432 | <p class="muted small"> |
| 435 | 433 | Until then, a personal access token below works and needs no |
| 436 | 434 | registration anywhere. |
| 437 | </p> | |
| 438 | </div>`; | |
| 435 | </p>`; | |
| 439 | 436 | } |
| 440 | return html`<div class="connect-path"> | |
| 441 | <h3>${icon("github")}GitHub, one click</h3> | |
| 442 | <p class="muted small"> | |
| 437 | return html`<p class="muted small"> | |
| 443 | 438 | A short code and a URL you type it into — from this machine or from your |
| 444 | 439 | phone. Nothing is typed into this terminal or this page. |
| 445 | 440 | </p> |
| 446 | 441 | <form method="post" action="/settings/origins/oauth"> |
| 447 | 442 | <input type="hidden" name="kind" value="github" /> |
| 448 | 443 | ${btn("Connect GitHub", { variant: "primary", icon: "github" })} |
| 449 | </form> | |
| 450 | </div>`; | |
| 444 | </form>`; | |
| 451 | 445 | } |
| 452 | 446 | |
| 453 | 447 | function forgejoSection( |
| @@ -457,16 +451,13 @@ function forgejoSection( | ||
| 457 | 451 | ): Html { |
| 458 | 452 | const back = callbackUri(ctx); |
| 459 | 453 | if (back === "") { |
| 460 | return html`<div class="connect-path"> | |
| 461 | <h3>${icon("forgejo")}Forgejo / Gitea, one click</h3> | |
| 462 | <p class="error-note"> | |
| 454 | return html`<p class="error-note"> | |
| 463 | 455 | This forge does not know its own public URL, so it cannot register a |
| 464 | 456 | callback for the code flow. Set |
| 465 | 457 | <span class="mono">FORGE_URL</span> to the base URL people reach it at |
| 466 | 458 | (or <span class="mono">FORGE_HOST</span> to the hostname it is served |
| 467 | 459 | on) and this path lights up. A pasted token works either way. |
| 468 | </p> | |
| 469 | </div>`; | |
| 460 | </p>`; | |
| 470 | 461 | } |
| 471 | 462 | // Two verbs, two endpoints, and HTML forbids nesting one form in another — |
| 472 | 463 | // so the "forget" form sits beside the authorize form and its button joins |
| @@ -572,9 +563,7 @@ function forgejoSection( | ||
| 572 | 563 | </div>` |
| 573 | 564 | : null; |
| 574 | 565 | |
| 575 | return html`<div class="connect-path"> | |
| 576 | <h3>${icon("forgejo")}Forgejo / Gitea, one click</h3> | |
| 577 | <p class="muted small"> | |
| 566 | return html`<p class="muted small"> | |
| 578 | 567 | Needs an app registration on the instance. Name it and this forge works |
| 579 | 568 | out which it is, then sends you there to authorize. |
| 580 | 569 | </p> |
| @@ -592,8 +581,26 @@ function forgejoSection( | ||
| 592 | 581 | ${btn("Continue", { variant: registered ? "secondary" : "primary" })} |
| 593 | 582 | </div> |
| 594 | 583 | </form> |
| 595 | ${authorize} ${registerForm} | |
| 596 | </div>`; | |
| 584 | ${authorize} ${registerForm}`; | |
| 585 | } | |
| 586 | ||
| 587 | /** | |
| 588 | * One way to connect, drawn as a list row that folds open on its form: the | |
| 589 | * caret leads (it turns when open), the brand glyph and the name are the | |
| 590 | * title, and the steps and the form are the body. | |
| 591 | */ | |
| 592 | function connectPath( | |
| 593 | id: string, | |
| 594 | glyph: IconName, | |
| 595 | title: Html | string, | |
| 596 | open: boolean, | |
| 597 | body: Html, | |
| 598 | ): Html { | |
| 599 | return row({ | |
| 600 | lead: icon("chevronRight"), | |
| 601 | title: html`${icon(glyph)}${title}`, | |
| 602 | fold: { id, open, body: html`<div class="card-body">${body}</div>` }, | |
| 603 | }); | |
| 597 | 604 | } |
| 598 | 605 | |
| 599 | 606 | /** |
| @@ -603,12 +610,7 @@ function forgejoSection( | ||
| 603 | 610 | * environment variable is a page that teaches people the product is fiddly. |
| 604 | 611 | */ |
| 605 | 612 | function tokenSection(values: ConnectValues): Html { |
| 606 | return html`<div class="connect-path"> | |
| 607 | <h3> | |
| 608 | ${icon("key")}Personal access token | |
| 609 | ${badge("recommended", { tone: "success" })} | |
| 610 | </h3> | |
| 611 | <p class="muted small"> | |
| 613 | return html`<p class="muted small"> | |
| 612 | 614 | Nothing to register, anywhere. The token is validated against the forge |
| 613 | 615 | before it is stored, encrypted at rest, and never shown again. |
| 614 | 616 | </p> |
| @@ -632,8 +634,7 @@ function tokenSection(values: ConnectValues): Html { | ||
| 632 | 634 | hint: "Needs repository and issue access. It is never displayed after this.", |
| 633 | 635 | })} |
| 634 | 636 | <div class="form-actions">${btn("Connect", { variant: "primary" })}</div> |
| 635 | </form> | |
| 636 | </div>`; | |
| 637 | </form>`; | |
| 637 | 638 | } |
| 638 | 639 | |
| 639 | 640 | /** The admin's list of hosts an origin may reach inside the network. */ |
| @@ -715,20 +716,28 @@ function originsView( | ||
| 715 | 716 | ) |
| 716 | 717 | : null} |
| 717 | 718 | ${card( |
| 718 | html`${tokenSection(values)} | |
| 719 | <details | |
| 720 | class="connect-more" | |
| 721 | ${values.mode !== "none" ? raw("open") : null} | |
| 722 | > | |
| 723 | <summary>One-click sign-in</summary> | |
| 724 | <p class="muted small"> | |
| 725 | Both of these are OAuth, and both need somebody to register an | |
| 726 | application once — for GitHub, per forge; for Forgejo and Gitea, per | |
| 727 | instance. Worth it if several people use this forge; a token is | |
| 728 | faster if it is just you. | |
| 729 | </p> | |
| 730 | ${githubSection(githubConfigured, callbackUri(ctx))} | |
| 731 | ${forgejoSection( | |
| 719 | html`<ul class="list"> | |
| 720 | ${connectPath( | |
| 721 | "connect-token", | |
| 722 | "key", | |
| 723 | html`Personal access token | |
| 724 | ${badge("recommended", { tone: "success" })}`, | |
| 725 | true, | |
| 726 | tokenSection(values), | |
| 727 | )} | |
| 728 | ${connectPath( | |
| 729 | "connect-github", | |
| 730 | "github", | |
| 731 | "GitHub, one click", | |
| 732 | values.mode !== "none", | |
| 733 | githubSection(githubConfigured, callbackUri(ctx)), | |
| 734 | )} | |
| 735 | ${connectPath( | |
| 736 | "connect-forgejo", | |
| 737 | "forgejo", | |
| 738 | "Forgejo / Gitea, one click", | |
| 739 | values.mode !== "none", | |
| 740 | forgejoSection( | |
| 732 | 741 | ctx, |
| 733 | 742 | values, |
| 734 | 743 | matched === undefined |
| @@ -738,12 +747,20 @@ function originsView( | ||
| 738 | 747 | clientId: matched.clientId, |
| 739 | 748 | id: matched.id, |
| 740 | 749 | }, |
| 741 | )} | |
| 742 | </details>`, | |
| 750 | ), | |
| 751 | )} | |
| 752 | </ul>`, | |
| 743 | 753 | { |
| 744 | 754 | id: "connect", |
| 755 | pad: false, | |
| 745 | 756 | head: html`${icon("plus")} |
| 746 | 757 | <h2>Connect an origin</h2>`, |
| 758 | foot: html`<span class="muted small" | |
| 759 | ><strong>One-click sign-in.</strong> Both of these are OAuth, and both | |
| 760 | need somebody to register an application once — for GitHub, per forge; for Forgejo and Gitea, | |
| 761 | per instance. Worth it if several people use this forge; a token is | |
| 762 | faster if it is just you.</span | |
| 763 | >`, | |
| 747 | 764 | }, |
| 748 | 765 | )}`; |
| 749 | 766 | } |
| @@ -1204,33 +1221,47 @@ function copyBtn(target: string, label: string): Html { | ||
| 1204 | 1221 | function handoff(url: string, shortLink: string, openLabel?: string): Html { |
| 1205 | 1222 | const payload = shortLink === "" ? url : shortLink; |
| 1206 | 1223 | const code = qrEncode(payload); |
| 1207 | return html`<div class="handoff"> | |
| 1208 | ${code.ok | |
| 1209 | ? raw(qrSvg(code.value, { title: "Scan to open the link" })) | |
| 1210 | : null} | |
| 1211 | <div class="handoff-links"> | |
| 1224 | return html`<div class="bar mt"> | |
| 1225 | ${code.ok | |
| 1226 | ? raw( | |
| 1227 | qrSvg(code.value, { | |
| 1228 | title: "Scan to open the link", | |
| 1229 | scale: qrScale(code.value.modules.length), | |
| 1230 | }), | |
| 1231 | ) | |
| 1232 | : null} | |
outdated
Value and Copy button inside nowrap/ellipsis |
||
| 1233 | </div> | |
| 1234 | <ul class="list setting-list mt"> | |
| 1212 | 1235 | ${shortLink === "" |
| 1213 | 1236 | ? null |
| 1214 | : html`<p class="muted small">Or open this on a phone:</p> | |
| 1215 | <div class="bar"> | |
| 1216 | <span class="mono" id="flow-short">${shortLink}</span> | |
| 1217 | ${copyBtn("flow-short", "Copy the short link")} | |
| 1218 | </div>`} | |
| 1219 | <p class="muted small mt">The full link:</p> | |
| 1220 | <div class="bar"> | |
| 1221 | <span class="mono small break" id="flow-url">${url}</span> | |
| 1222 | ${copyBtn("flow-url", "Copy the link")} | |
| 1223 | </div> | |
| 1224 | <div class="bar mt"> | |
| 1225 | ${btn(openLabel ?? "Open the verification page", { | |
| 1226 | href: url, | |
| 1227 | variant: "primary", | |
| 1228 | icon: "external", | |
| 1229 | external: true, | |
| 1230 | })} | |
| 1231 | </div> | |
| 1232 | </div> | |
| 1233 | </div>`; | |
| 1237 | : row({ | |
outdated
The full URL is truncated with an ellipsis here; |
||
| 1238 | title: "Or open this on a phone:", | |
| 1239 | meta: html`<span class="mono" id="flow-short">${shortLink}</span>`, | |
| 1240 | trail: copyBtn("flow-short", "Copy the short link"), | |
| 1241 | })} | |
| 1242 | ${row({ | |
| 1243 | title: "The full link:", | |
| 1244 | meta: html`<span class="mono small" id="flow-url">${url}</span>`, | |
| 1245 | trail: copyBtn("flow-url", "Copy the link"), | |
| 1246 | })} | |
| 1247 | </ul> | |
| 1248 | <div class="bar mt"> | |
| 1249 | ${btn(openLabel ?? "Open the verification page", { | |
| 1250 | href: url, | |
| 1251 | variant: "primary", | |
| 1252 | icon: "external", | |
| 1253 | external: true, | |
| 1254 | })} | |
| 1255 | </div>`; | |
| 1256 | } | |
| 1257 | ||
| 1258 | /** | |
| 1259 | * The module size that keeps the code at most 200 px square: the encoder sizes | |
| 1260 | * the SVG from it, so a long URL (more modules) must not make a code wider | |
| 1261 | * than a 360 px phone can hold beside the page's padding. | |
| 1262 | */ | |
| 1263 | function qrScale(modules: number): number { | |
| 1264 | return Math.max(1, Math.min(4, Math.floor(200 / modules))); | |
| 1234 | 1265 | } |
| 1235 | 1266 | |
| 1236 | 1267 | function notFoundNote(): Html { |
test/web-origins.test.ts +64
| @@ -24,6 +24,7 @@ import { getRepo } from "../src/core/repos.ts"; | ||
| 24 | 24 | import { getUserByName } from "../src/core/users.ts"; |
| 25 | 25 | import { forgetProviders } from "../src/origins/providers/index.ts"; |
| 26 | 26 | import { advance, createJob, settled } from "../src/origins/jobs.ts"; |
| 27 | import { icon } from "../src/web/ui.ts"; | |
| 27 | 28 | import { quietLogs, withoutEnv } from "./helpers/globals.ts"; |
| 28 | 29 | import { |
| 29 | 30 | newFakeOAuth, |
| @@ -202,6 +203,16 @@ describe("origins on the web", () => { | ||
| 202 | 203 | html.indexOf("One-click sign-in"), |
| 203 | 204 | ); |
| 204 | 205 | expect(html).toContain("recommended"); |
| 206 | // Drawn with §5's rows and buttons, not the page's own classes. | |
| 207 | for (const gone of ["handoff", "connect-more", "connect-path"]) { | |
| 208 | expect(html).not.toContain(gone); | |
| 209 | } | |
| 210 | expect(html).toContain('id="connect-token"'); | |
| 211 | expect(html).toContain('action="/settings/origins/token"'); | |
| 212 | expect(html).toContain('name="token"'); | |
| 213 | expect(html).toContain('action="/settings/origins/oauth"'); | |
| 214 | expect(html).toContain("GitHub, one click"); | |
| 215 | expect(html).toContain("Needs an app registration on the instance"); | |
| 205 | 216 | // No GitHub client id is configured yet, so the button is replaced by a |
| 206 | 217 | // hint rather than left there to fail. |
| 207 | 218 | expect(html).toContain("FORGE_GITHUB_CLIENT_ID"); |
| @@ -212,6 +223,37 @@ describe("origins on the web", () => { | ||
| 212 | 223 | |
| 213 | 224 | // ---- the token path ---- |
| 214 | 225 | |
| 226 | test("the connect rows lead with the caret, and the one-click folds open only once a form was used", async () => { | |
| 227 | const fold = (page: string, id: string) => { | |
| 228 | const m = new RegExp(`<details id="${id}"\\s*(open)?\\s*>\\s*<summary[^>]*>([\\s\\S]*?)</summary>`).exec(page); | |
| 229 | expect(m).not.toBeNull(); | |
| 230 | return { open: m![1] === "open", summary: m![2]! }; | |
| 231 | }; | |
| 232 | const closed = await (await asUser(alice, "/settings/origins")).text(); | |
| 233 | const used = await (await asUser(alice, "/settings/origins?host=codeberg.org")).text(); | |
| 234 | for (const [id, brand] of [ | |
| 235 | ["connect-token", "key"], | |
| 236 | ["connect-github", "github"], | |
| 237 | ["connect-forgejo", "forgejo"], | |
| 238 | ] as const) { | |
| 239 | for (const page of [closed, used]) { | |
| 240 | const { summary } = fold(page, id); | |
| 241 | // The lead is the caret (it is rotated when open); the brand glyph | |
| 242 | // is in the title beside the name. | |
| 243 | const lead = /<span class="row-lead">([\s\S]*?)<\/span>/.exec(summary)![1]!; | |
| 244 | expect(lead).toBe(icon("chevronRight").text); | |
| 245 | expect(lead).not.toBe(icon(brand).text); | |
| 246 | expect(/<span\s+class="row-title"[^>]*>([\s\S]*?)<\/span\s*>/.exec(summary)![1]).toContain(icon(brand).text); | |
| 247 | } | |
| 248 | } | |
| 249 | expect(fold(closed, "connect-token").open).toBe(true); | |
| 250 | expect(fold(closed, "connect-github").open).toBe(false); | |
| 251 | expect(fold(closed, "connect-forgejo").open).toBe(false); | |
| 252 | expect(fold(used, "connect-token").open).toBe(true); | |
| 253 | expect(fold(used, "connect-github").open).toBe(true); | |
| 254 | expect(fold(used, "connect-forgejo").open).toBe(true); | |
| 255 | }); | |
| 256 | ||
| 215 | 257 | test("connecting with a token stores it and never echoes it", async () => { |
| 216 | 258 | const posted = await asUser(alice, "/settings/origins/token", { |
| 217 | 259 | form: { host: forgejo.url, token: ORIGIN_TOKEN }, |
| @@ -527,6 +569,28 @@ describe("origins on the web", () => { | ||
| 527 | 569 | expect(page).toContain('data-copy="device-code"'); |
| 528 | 570 | expect(page).toContain('data-copy="flow-short"'); |
| 529 | 571 | expect(page).toContain('data-copy="flow-url"'); |
| 572 | for (const gone of ["handoff", "connect-more", "connect-path"]) { | |
| 573 | expect(page).not.toContain(gone); | |
| 574 | } | |
| 575 | // Values and their copy buttons sit outside the ellipsizing `.row-title`, | |
| 576 | // in a list whose rows wrap. | |
| 577 | expect(page).toContain('class="list setting-list'); | |
| 578 | for (const id of ["flow-short", "flow-url"]) { | |
| 579 | expect(page).toMatch(new RegExp(`row-meta[^>]*>\\s*<span[^>]*id="${id}"`)); | |
| 580 | } | |
| 581 | const titles = [...page.matchAll(/<span\s+class="row-title"[^>]*>([\s\S]*?)<\/span\s*>/g)]; | |
| 582 | expect(titles.length).toBeGreaterThan(0); | |
| 583 | for (const t of titles) { | |
| 584 | expect(t[1]).not.toContain("data-copy"); | |
| 585 | expect(t[1]).not.toContain('id="flow-'); | |
| 586 | } | |
| 587 | expect(page).toMatch(/row-trail[^>]*>\s*<button[^>]*data-copy="flow-url"/); | |
| 588 | // The QR is capped: no wider than 200 px. | |
| 589 | const width = Number(/<svg[^>]*\swidth="(\d+)"/.exec(page)?.[1]); | |
| 590 | expect(width).toBeLessThanOrEqual(200); | |
| 591 | expect(page).toContain("Or open this on a phone:"); | |
| 592 | expect(page).toContain("The full link:"); | |
| 593 | expect(page).toContain("Open the verification page"); | |
| 530 | 594 | // Open leaves the forge safely, and the text stays selectable with no JS. |
| 531 | 595 | expect(page).toContain('rel="noopener noreferrer"'); |
| 532 | 596 | expect(page).toContain('target="_blank"'); |
new above
offline
leadon a folded row is the caret, rotated 90° when open (components.ts:410). Brand glyphs here turn sideways when the row is open. Puticon(lead)inside the title and leave the lead for the caret.