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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/4154-the-origins-page-s-handoff-and-connect-p → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/4154-the-origins-page-s-handoff-and-connect-p was merged into main as e4dea78.

Review

Sign in to review this change.
2 files changed +164 −69
src/web/pages/origins.ts +100 −69
@@ -404,9 +404,7 @@ function githubSection(configured: boolean, callback: string): Html {
404404 if (!configured) {
405405 const back =
406406 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">
410408 Needs a one-time OAuth app setup: no GitHub application is registered
411409 for this forge yet, so the device flow has no client id to use. Three
412410 steps, once, for every user of this forge:
@@ -434,20 +432,16 @@ function githubSection(configured: boolean, callback: string): Html {
434432 <p class="muted small">
435433 Until then, a personal access token below works and needs no
436434 registration anywhere.
437 </p>
438 </div>`;
435 </p>`;
439436 }
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">
443438 A short code and a URL you type it into — from this machine or from your
444439 phone. Nothing is typed into this terminal or this page.
445440 </p>
446441 <form method="post" action="/settings/origins/oauth">
447442 <input type="hidden" name="kind" value="github" />
448443 ${btn("Connect GitHub", { variant: "primary", icon: "github" })}
449 </form>
450 </div>`;
444 </form>`;
451445 }
452446
453447 function forgejoSection(
@@ -457,16 +451,13 @@ function forgejoSection(
457451 ): Html {
458452 const back = callbackUri(ctx);
459453 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">
463455 This forge does not know its own public URL, so it cannot register a
464456 callback for the code flow. Set
465457 <span class="mono">FORGE_URL</span> to the base URL people reach it at
466458 (or <span class="mono">FORGE_HOST</span> to the hostname it is served
467459 on) and this path lights up. A pasted token works either way.
468 </p>
469 </div>`;
460 </p>`;
470461 }
471462 // Two verbs, two endpoints, and HTML forbids nesting one form in another —
472463 // so the "forget" form sits beside the authorize form and its button joins
@@ -572,9 +563,7 @@ function forgejoSection(
572563 </div>`
573564 : null;
574565
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">
578567 Needs an app registration on the instance. Name it and this forge works
579568 out which it is, then sends you there to authorize.
580569 </p>
@@ -592,8 +581,26 @@ function forgejoSection(
592581 ${btn("Continue", { variant: registered ? "secondary" : "primary" })}
593582 </div>
594583 </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({
outdated

lead on a folded row is the caret, rotated 90° when open (components.ts:410). Brand glyphs here turn sideways when the row is open. Put icon(lead) inside the title and leave the lead for the caret.

600 lead: icon("chevronRight"),
601 title: html`${icon(glyph)}${title}`,
602 fold: { id, open, body: html`<div class="card-body">${body}</div>` },
603 });
597604 }
598605
599606 /**
@@ -603,12 +610,7 @@ function forgejoSection(
603610 * environment variable is a page that teaches people the product is fiddly.
604611 */
605612 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">
612614 Nothing to register, anywhere. The token is validated against the forge
613615 before it is stored, encrypted at rest, and never shown again.
614616 </p>
@@ -632,8 +634,7 @@ function tokenSection(values: ConnectValues): Html {
632634 hint: "Needs repository and issue access. It is never displayed after this.",
633635 })}
634636 <div class="form-actions">${btn("Connect", { variant: "primary" })}</div>
635 </form>
636 </div>`;
637 </form>`;
637638 }
638639
639640 /** The admin's list of hosts an origin may reach inside the network. */
@@ -715,20 +716,28 @@ function originsView(
715716 )
716717 : null}
717718 ${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(
732741 ctx,
733742 values,
734743 matched === undefined
@@ -738,12 +747,20 @@ function originsView(
738747 clientId: matched.clientId,
739748 id: matched.id,
740749 },
741 )}
742 </details>`,
750 ),
751 )}
752 </ul>`,
743753 {
744754 id: "connect",
755 pad: false,
745756 head: html`${icon("plus")}
746757 <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 >`,
747764 },
748765 )}`;
749766 }
@@ -1204,33 +1221,47 @@ function copyBtn(target: string, label: string): Html {
12041221 function handoff(url: string, shortLink: string, openLabel?: string): Html {
12051222 const payload = shortLink === "" ? url : shortLink;
12061223 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 .row-title: the button can clip off at 360px. Use a .setting-list, the value as wrapping meta, the button in trail.

1233 </div>
1234 <ul class="list setting-list mt">
12121235 ${shortLink === ""
12131236 ? 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; break is overridden by nowrap. A person cannot read or select the whole link. Let it wrap.

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)));
12341265 }
12351266
12361267 function notFoundNote(): Html {
test/web-origins.test.ts +64
@@ -24,6 +24,7 @@ import { getRepo } from "../src/core/repos.ts";
2424 import { getUserByName } from "../src/core/users.ts";
2525 import { forgetProviders } from "../src/origins/providers/index.ts";
2626 import { advance, createJob, settled } from "../src/origins/jobs.ts";
27 import { icon } from "../src/web/ui.ts";
2728 import { quietLogs, withoutEnv } from "./helpers/globals.ts";
2829 import {
2930 newFakeOAuth,
@@ -202,6 +203,16 @@ describe("origins on the web", () => {
202203 html.indexOf("One-click sign-in"),
203204 );
204205 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");
205216 // No GitHub client id is configured yet, so the button is replaced by a
206217 // hint rather than left there to fail.
207218 expect(html).toContain("FORGE_GITHUB_CLIENT_ID");
@@ -212,6 +223,37 @@ describe("origins on the web", () => {
212223
213224 // ---- the token path ----
214225
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
215257 test("connecting with a token stores it and never echoes it", async () => {
216258 const posted = await asUser(alice, "/settings/origins/token", {
217259 form: { host: forgejo.url, token: ORIGIN_TOKEN },
@@ -527,6 +569,28 @@ describe("origins on the web", () => {
527569 expect(page).toContain('data-copy="device-code"');
528570 expect(page).toContain('data-copy="flow-short"');
529571 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");
530594 // Open leaves the forge safely, and the text stays selectable with no JS.
531595 expect(page).toContain('rel="noopener noreferrer"');
532596 expect(page).toContain('target="_blank"');

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs