Public: anyone can read this; the crew can change it.
Connecting an origin, the device code and the authorize step, speaks the home page's language #9908
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9905-connecting-an-origin-the-device-code-and → main 3 commits fork 2 threads · 2 unresolved
2 files
changed +123
−54
src/web/pages/origin-flow.ts +44 −54
| @@ -17,7 +17,7 @@ import { readForm } from "../form.ts"; | ||
| 17 | 17 | import { html, raw, type Html } from "../html.ts"; |
| 18 | 18 | import { page, redirect } from "../layout.ts"; |
| 19 | 19 | import { pollJs } from "../client.ts"; |
| 20 | import { btn, card, icon, row } from "../ui.ts"; | |
| 20 | import { btn, icon, row } from "../ui.ts"; | |
| 21 | 21 | import { |
| 22 | 22 | accountOnly, |
| 23 | 23 | hostLabel, |
| @@ -64,25 +64,25 @@ export function originFlowPage(ctx: Ctx): Response { | ||
| 64 | 64 | base === "" || flow.shortCode === "" ? "" : `${base}/go/${flow.shortCode}`; |
| 65 | 65 | const body = |
| 66 | 66 | flow.flow === "device" |
| 67 | ? html`${card( | |
| 68 | html`<p class="muted small"> | |
| 69 | Type this code at | |
| 70 | <a href="${flow.verificationUri}" rel="noreferrer noopener" | |
| 71 | >${flow.verificationUri}</a | |
| 72 | >. Any device with a browser will do — this page waits. | |
| 73 | </p> | |
| 74 | <div class="bar"> | |
| 75 | <p class="device-code" id="device-code">${flow.userCode}</p> | |
| 76 | ${copyBtn("device-code", "Copy the code")} | |
| 77 | </div> | |
| 78 | ${handoff(flow.verificationUri, shortLink)}`, | |
| 79 | { | |
| 80 | head: html`${icon("github")} | |
| 81 | <h2>Enter this code</h2>`, | |
| 82 | }, | |
| 83 | )}` | |
| 84 | : html`${card( | |
| 85 | authorize === undefined | |
| 67 | ? html`<section aria-labelledby="h-flow"> | |
| 68 | <h2 id="h-flow">Enter this code</h2> | |
| 69 | <p class="muted"> | |
| 70 | Type it at | |
| 71 | <a href="${flow.verificationUri}" rel="noreferrer noopener" | |
| 72 | >${flow.verificationUri}</a | |
| 73 | >. Any device with a browser will do — this page waits. | |
| 74 | </p> | |
| 75 | <div class="bar"> | |
| 76 | <p class="device-code" id="device-code">${flow.userCode}</p> | |
| 77 | ${copyBtn("device-code", "Copy the code")} | |
| 78 | </div> | |
| 79 | ${handoff(flow.verificationUri, shortLink)} | |
| 80 | </section>` | |
| 81 | : html`<section aria-labelledby="h-flow"> | |
| 82 | ${forge | |
| 83 | ? html`<h2 id="h-flow">Sign in at ${hostLabel(flow.webUrl)}</h2>` | |
| 84 | : html`<h2 id="h-flow">Authorize on the instance</h2>`} | |
| 85 | ${authorize === undefined | |
| 86 | 86 | ? html`<p> |
| 87 | 87 | This connect cannot be continued from here — the authorization |
| 88 | 88 | link was lost. Start it again from Origins. |
| @@ -98,15 +98,8 @@ export function originFlowPage(ctx: Ctx): Response { | ||
| 98 | 98 | <span class="mono">${hostLabel(flow.webUrl)}</span> and |
| 99 | 99 | ${forge ? "say yes" : "approve the application"}. You will come straight back here. |
| 100 | 100 | </p> |
| 101 | ${handoff(authorize, shortLink, forge ? `Continue to ${hostLabel(flow.webUrl)}` : "Continue to the instance")}`, | |
| 102 | { | |
| 103 | head: forge | |
| 104 | ? html`${icon("globe")} | |
| 105 | <h2>Sign in at ${hostLabel(flow.webUrl)}</h2>` | |
| 106 | : html`${icon("forgejo")} | |
| 107 | <h2>Authorize on the instance</h2>`, | |
| 108 | }, | |
| 109 | )}`; | |
| 101 | ${handoff(authorize, shortLink, forge ? `Continue to ${hostLabel(flow.webUrl)}` : "Continue to the instance")}`} | |
| 102 | </section>`; | |
| 110 | 103 | |
| 111 | 104 | const waiting = html`<form |
| 112 | 105 | class="bar mt" |
| @@ -266,31 +259,28 @@ function expiredFlow( | ||
| 266 | 259 | title: "Connecting an origin", |
| 267 | 260 | body: html`<div class="page-head"><h1>Connecting an origin</h1></div> |
| 268 | 261 | <div class="narrow-wide"> |
| 269 | ${card( | |
| 270 | html`<p class="mb"> | |
| 271 | This connect link expired. Nothing was connected. Start again: | |
| 272 | </p> | |
| 273 | <form method="post" action="/settings/origins/oauth"> | |
| 274 | <input type="hidden" name="host" value="${flow.webUrl}" /> | |
| 275 | ${flow.kind === "github" | |
| 276 | ? html`<input type="hidden" name="kind" value="github" />` | |
| 277 | : null} | |
| 278 | ${then === undefined | |
| 279 | ? null | |
| 280 | : html`<input type="hidden" name="then" value="${then === "/bring" ? "bring" : "migrate"}" />`} | |
| 281 | ${link === undefined | |
| 282 | ? null | |
| 283 | : html`<input type="hidden" name="link" value="${link}" />`} | |
| 284 | ${btn(`Connect ${hostLabel(flow.webUrl)}`, { | |
| 285 | variant: "primary", | |
| 286 | icon: "external", | |
| 287 | })} | |
| 288 | </form>`, | |
| 289 | { | |
| 290 | head: html`${icon("alert")} | |
| 291 | <h2>This connect link expired</h2>`, | |
| 292 | }, | |
| 293 | )} | |
| 262 | <section aria-labelledby="h-expired"> | |
| 263 | <h2 id="h-expired">This connect link expired</h2> | |
| 264 | <p class="mb"> | |
| 265 | Nothing was connected. Start again: | |
| 266 | </p> | |
| 267 | <form method="post" action="/settings/origins/oauth"> | |
| 268 | <input type="hidden" name="host" value="${flow.webUrl}" /> | |
| 269 | ${flow.kind === "github" | |
| 270 | ? html`<input type="hidden" name="kind" value="github" />` | |
| 271 | : null} | |
| 272 | ${then === undefined | |
| 273 | ? null | |
| 274 | : html`<input type="hidden" name="then" value="${then === "/bring" ? "bring" : "migrate"}" />`} | |
Principle 1 and 8: the heading already says the link expired. Start the paragraph with 'Nothing was connected. Start again:'. |
||
| 275 | ${link === undefined | |
| 276 | ? null | |
| 277 | : html`<input type="hidden" name="link" value="${link}" />`} | |
| 278 | ${btn(`Connect ${hostLabel(flow.webUrl)}`, { | |
| 279 | variant: "primary", | |
| 280 | icon: "external", | |
| 281 | })} | |
| 282 | </form> | |
| 283 | </section> | |
| 294 | 284 | </div>`, |
| 295 | 285 | }); |
| 296 | 286 | } |
test/origin-flow-language.test.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** Connecting an origin speaks the home page's language (#9905): one heading, plain sections, no card. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createSession } from "../src/core/sessions.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { createFlow } from "../src/origins/oauth.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { everyoneIsAMember } from "./helpers/members.ts"; | |
| 12 | ||
| 13 | let dir = ""; | |
| 14 | let app: App; | |
| 15 | let servers: Servers; | |
| 16 | let cookie = ""; | |
| 17 | let userId = ""; | |
| 18 | ||
| 19 | beforeAll(async () => { | |
| 20 | dir = await mkdtemp(join(tmpdir(), "forge-flow-lang-")); | |
| 21 | app = createApp({ dataDir: dir, port: 0 }); | |
| 22 | everyoneIsAMember(app.db); | |
| 23 | servers = await serve(app); | |
| 24 | const made = await createUser(app.db, { | |
| 25 | username: "ada", email: "[email protected]", password: "a-long-flow-lang-passphrase", | |
| 26 | origin: "seed", trusted: true, | |
| 27 | }); | |
| 28 | if (!made.ok) throw new Error(made.error); | |
| 29 | userId = made.value.id; | |
| 30 | cookie = `forge_session=${createSession(app.db, userId).token}`; | |
| 31 | }); | |
| 32 | afterAll(async () => { | |
| 33 | servers.stop(); | |
| 34 | app.close(); | |
| 35 | await rm(dir, { recursive: true, force: true }); | |
| 36 | }); | |
| 37 | ||
| 38 | const device = (ttlMs?: number) => | |
| 39 | createFlow(app.db, dir, { | |
| 40 | userId, | |
| 41 | flow: "device", | |
| 42 | app: { kind: "github", apiUrl: "https://api.github.com", webUrl: "https://github.com", clientId: "cid", clientSecret: "" } as never, | |
| 43 | deviceCode: "secret-device-code-xyz", | |
| 44 | userCode: "WDJB-MJHT", | |
| 45 | verificationUri: "https://github.com/login/device", | |
| 46 | target: "https://github.com/login/device", | |
| 47 | ...(ttlMs === undefined ? {} : { ttlMs }), | |
| 48 | }); | |
| 49 | ||
| 50 | const get = async (id: string): Promise<string> => { | |
| 51 | const res = await fetch(`${servers.url}/settings/origins/flows/${id}`, { headers: { cookie } }); | |
| 52 | expect(res.status).toBe(200); | |
| 53 | const text = await res.text(); | |
| 54 | return text.slice(text.indexOf("<main"), text.indexOf("</main>")); | |
| 55 | }; | |
| 56 | ||
| 57 | function plain(page: string): void { | |
| 58 | expect(page.match(/<h1[\s>]/g)?.length).toBe(1); | |
| 59 | expect(page).not.toContain('class="card'); | |
| 60 | expect(page).not.toContain("card-body"); | |
| 61 | expect(page).not.toContain("secret-device-code-xyz"); | |
| 62 | } | |
| 63 | ||
| 64 | test("a waiting device grant: the code and where to type it, no card", async () => { | |
| 65 | const page = await get(device().id); | |
| 66 | plain(page); | |
| 67 | expect(page).toContain("WDJB-MJHT"); | |
| 68 | expect(page).toMatch(/<a href="https:\/\/github.com\/login\/device" rel="noreferrer noopener"/); | |
| 69 | expect(page).toContain("/poll"); | |
| 70 | }); | |
| 71 | ||
| 72 | test("an expired flow: the notice and the start-again form, no card", async () => { | |
| 73 | const flow = device(-60_000); | |
| 74 | const page = await get(flow.id); | |
| 75 | plain(page); | |
| 76 | expect(page).toContain("This connect link expired"); | |
| 77 | expect(page).toContain('action="/settings/origins/oauth"'); | |
| 78 | expect(page).not.toContain("WDJB-MJHT"); | |
| 79 | }); |
Principle 1: the heading says 'Enter this code' and the line below says 'Type this code at'. Say it once, e.g. 'Type it at'.