Public: anyone can read this; the crew can change it.
Settings › Trusted speaks the home page's language #9796
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9792-settings-trusted-speaks-the-home-page-s → main 3 commits fork 3 threads · 3 unresolved
docs/design/areas/settings.md +8
| @@ -94,3 +94,11 @@ Settings › Accounts (`src/web/pages/accounts.ts`) adds no component class. As | ||
| 94 | 94 | accounts are `row()`s in `<ul class="list setting-list" id="accounts-list">` under `<h2>Who is on this forge</h2>`, the acts |
| 95 | 95 | (Sign-ins, Verify, Suspend, Remove) in the row's `after` slot; the mail, ownerless-organization, sign-in reach and |
| 96 | 96 | confirm/refusal notices are plain `<section class="settings-block">` blocks with an `<h2>`. `test/accounts-rows.test.ts` holds the shape. |
| 97 | ||
| 98 | Settings › Trusted (`trustedPage()` in `src/web/pages/trusted.ts`, #9792) adds no component class. As on the home page it is | |
| 99 | headings over rows: Fold-in, Trusted sources and Trust a source are each a `<section class="settings-block">` with an | |
| 100 | `<h2>`. Each trusted entry is a `row()` in `<ul class="list setting-list" id="trusted-list">`; its state is a badge in the | |
| 101 | title, each with a glyph (Trusted with a check, Ends soon with a clock, Lapsed with a ban), never colour alone; the row's | |
| 102 | `data-state` is `open`, or `draft` once lapsed, and Remove and Revert landings sit in its trail as | |
| 103 | `.row-fields` forms. With no entry, one plain sentence: no one is trusted, so every pull waits for review. The forms post to the same routes as | |
| 104 | before. `test/trusted-sources.test.ts` holds the shape. |
src/web/pages/trusted.ts +30 −23
| @@ -17,7 +17,7 @@ import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | ||
| 17 | 17 | import { field, readForm } from "../form.ts"; |
| 18 | 18 | import { html } from "../html.ts"; |
| 19 | 19 | import { flashTo, page } from "../layout.ts"; |
| 20 | import { badge, btn, card, icon, row, time } from "../ui.ts"; | |
| 20 | import { badge, btn, icon, row, time } from "../ui.ts"; | |
| 21 | 21 | import { accountSections } from "./origins.ts"; |
| 22 | 22 | |
| 23 | 23 | function owner(ctx: Ctx): Response | undefined { |
| @@ -42,8 +42,9 @@ export function trustedPage(ctx: Ctx): Response { | ||
| 42 | 42 | const now = new Date(); |
| 43 | 43 | const stopped = foldInStopped(ctx.app.db); |
| 44 | 44 | const entries = listTrusted(ctx.app.db); |
| 45 | const body = html`${card( | |
| 46 | html`<div class="card-body"><p class="muted small"> | |
| 45 | const body = html`<section class="settings-block"> | |
| 46 | <h2>Fold-in</h2> | |
| 47 | <p class="muted small"> | |
| 47 | 48 | Work from a trusted source flows in as it appears and lands by true merge when its checks pass, |
| 48 | 49 | with no person's review. It never skips the check, the boot proof or the walls: a pull touching |
| 49 | 50 | secrets, credentials, permissions, the contract or release machinery still waits for you. Anyone |
| @@ -53,25 +54,32 @@ export function trustedPage(ctx: Ctx): Response { | ||
| 53 | 54 | <form method="post" action="/settings/trusted/stop" class="row-fields"> |
| 54 | 55 | <input type="hidden" name="stop" value="${stopped ? "0" : "1"}" /> |
| 55 | 56 | ${stopped |
| 56 | ? html`${badge("Folding in is stopped", { variant: "outline" })} | |
| 57 | ? html`${badge("Folding in is stopped", { variant: "outline", icon: "lock" })} | |
| 57 | 58 | <span class="muted small">by ${stopped.by}, ${time(stopped.at, { date: true })}</span> |
| 58 | 59 | ${btn("Resume folding in", { size: "sm" })}` |
| 59 | 60 | : btn("Stop all folding in", { size: "sm", variant: "outline" })} |
| 60 | </form></div>`, | |
| 61 | { head: html`${icon("lock")}<h2>Fold-in</h2>` }, | |
| 62 | )} | |
| 63 | ${card( | |
| 64 | html`<ul class="list setting-list"> | |
| 65 | ${entries.length === 0 | |
| 66 | ? html`<li class="muted small">No one is trusted. Everyone waits for review.</li>` | |
| 67 | : entries.map((s) => { | |
| 61 | </form> | |
| 62 | </section> | |
| 63 | <section class="settings-block"> | |
| 64 | <h2>Trusted sources</h2> | |
| 65 | ${entries.length === 0 | |
| 66 | ? html`<p class="muted small">No one is trusted, so every pull waits for review.</p>` | |
| 67 | : html`<ul class="list setting-list" id="trusted-list"> | |
| 68 | ${entries.map((s) => { | |
| 68 | 69 | const lapsed = s.until <= now.toISOString(); |
| 70 | const soon = !lapsed && endsSoon(s, now); | |
| 69 | 71 | return row({ |
| 70 | 72 | lead: icon("user"), |
| 71 | title: html`${s.account} <span class="muted small mono">${s.host}</span>`, | |
| 72 | meta: html`${lapsed ? "Lapsed, waits for review, since" : "Until"} | |
| 73 | state: lapsed ? "draft" : "open", | |
| 74 | title: html`${s.account} <span class="muted small mono">${s.host}</span> | |
| 75 | ${lapsed | |
| 76 | ? badge("Lapsed", { variant: "outline", icon: "ban" }) | |
| 77 | : soon | |
| 78 | ? badge("Ends soon", { tone: "attn", icon: "clock" }) | |
outdated
The Ends soon badge relies on tone attn and Lapsed has no glyph. The text carries the state so it is not colour alone, but give Ends soon and Lapsed an icon like Trusted has so all three read the same shape; keep the tone as extra. |
||
| 79 | : badge("Trusted", { variant: "secondary", icon: "check" })}`, | |
| 80 | meta: html`${lapsed ? "Waits for review since" : "Until"} | |
| 73 | 81 | ${time(s.until, { date: true })} |
| 74 | (${leftWords(s.until, now)})${!lapsed && endsSoon(s, now) ? ", ends soon" : ""} · set by ${s.setBy}`, | |
| 82 | (${leftWords(s.until, now)}) · set by ${s.setBy}`, | |
| 75 | 83 | trail: html`<form class="row-fields" method="post" action="/settings/trusted/${s.id}/remove"> |
| 76 | 84 | ${btn("Remove", { size: "sm", variant: "outline", label: `Remove ${s.account} on ${s.host}` })} |
| 77 | 85 | </form> |
| @@ -83,19 +91,18 @@ export function trustedPage(ctx: Ctx): Response { | ||
| 83 | 91 | </form>`, |
| 84 | 92 | }); |
| 85 | 93 | })} |
| 86 | </ul>`, | |
| 87 | { head: html`${icon("users")}<h2>Trusted sources</h2>`, pad: false }, | |
| 88 | )} | |
| 89 | ${card( | |
| 90 | html`<form method="post" action="/settings/trusted" class="stack"> | |
| 94 | </ul>`} | |
| 95 | </section> | |
| 96 | <section class="settings-block"> | |
| 97 | <h2>Trust a source</h2> | |
| 98 | <form method="post" action="/settings/trusted" class="stack"> | |
| 91 | 99 | <label>Account <input class="input" name="account" required maxlength="64" autocomplete="off" aria-describedby="acct-hint" /></label> |
| 92 | 100 | <p id="acct-hint" class="muted small">For a downstream forge's proposals write <code>child:name</code>, the name its proposal token was minted for.</p> |
| 93 | 101 | <label>Forge <input class="input" name="host" required maxlength="260" placeholder="git.open-platform.sh" autocomplete="off" /></label> |
| 94 | 102 | <label>Until <input class="input" name="until" type="date" required /></label> |
| 95 | 103 | ${btn("Trust", { icon: "check" })} |
| 96 | </form>`, | |
| 97 | { head: html`${icon("plus")}<h2>Trust a source</h2>` }, | |
| 98 | )}`; | |
| 104 | </form> | |
| 105 | </section>`; | |
| 99 | 106 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 100 | 107 | return page({ |
| 101 | 108 | ctx, |
test/trusted-sources.test.ts +102
| @@ -239,4 +239,106 @@ describe("the settings page", () => { | ||
| 239 | 239 | servers.stop(); |
| 240 | 240 | } |
| 241 | 241 | }); |
| 242 | ||
| 243 | test("is headings over rows, each entry's state a badge, and no card (#9792)", async () => { | |
| 244 | const { serve } = await import("../src/http/server.ts"); | |
| 245 | const { createSession } = await import("../src/core/sessions.ts"); | |
| 246 | const servers = await serve(app); | |
| 247 | try { | |
| 248 | const res = await fetch(`${servers.url}/settings/trusted`, { | |
| 249 | headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` }, | |
| 250 | }); | |
| 251 | const page = await res.text(); | |
| 252 | const main = page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 253 | for (const h of ["Fold-in", "Trusted sources", "Trust a source"]) { | |
| 254 | const at = main.indexOf(`<h2>${h}</h2>`); | |
| 255 | expect(at).toBeGreaterThan(-1); | |
| 256 | expect(main.lastIndexOf('<section class="settings-block">', at)).toBeGreaterThan(-1); | |
| 257 | } | |
| 258 | for (const boxed of ['class="card', "card-head", "card-body"]) expect(main).not.toContain(boxed); | |
| 259 | const list = main.slice(main.indexOf('<ul class="list setting-list" id="trusted-list">')); | |
| 260 | expect(list.length).toBeLessThan(main.length); | |
| 261 | const first = list.slice(0, list.indexOf("</li>")); | |
Only the first row and the word Trusted are checked. Add cases that would fail without the change: an entry with a past until renders the Lapsed badge, one within the ends-soon window renders Ends soon, and with no entries the page has the empty sentence and no trusted-list ul. |
||
| 262 | expect(first).toContain('class="row-title"'); | |
| 263 | expect(first).toContain('class="badge"'); | |
| 264 | expect(first).toContain("Trusted"); | |
| 265 | // Same routes, same forms. | |
| 266 | expect(main).toContain('action="/settings/trusted/stop"'); | |
| 267 | expect(main).toContain('action="/settings/trusted/revert"'); | |
| 268 | expect(main).toMatch(/action="\/settings\/trusted\/[^"]+\/remove"/); | |
| 269 | expect(main).toContain('action="/settings/trusted"'); | |
| 270 | } finally { | |
| 271 | servers.stop(); | |
| 272 | } | |
| 273 | }); | |
| 274 | ||
| 275 | /** The page's `<main>` as `who` sees it on `on`. */ | |
| 276 | async function mainOf(on: App, who: User): Promise<string> { | |
| 277 | const { serve } = await import("../src/http/server.ts"); | |
| 278 | const { createSession } = await import("../src/core/sessions.ts"); | |
| 279 | const servers = await serve(on); | |
| 280 | try { | |
| 281 | const res = await fetch(`${servers.url}/settings/trusted`, { | |
| 282 | headers: { cookie: `forge_session=${createSession(on.db, who.id).token}` }, | |
| 283 | }); | |
| 284 | const page = await res.text(); | |
| 285 | return page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 286 | } finally { | |
| 287 | servers.stop(); | |
| 288 | } | |
| 289 | } | |
| 290 | ||
| 291 | /** The `<li>` of the row whose title names `account`. */ | |
| 292 | const rowOf = (main: string, account: string): string => { | |
| 293 | const at = main.indexOf(`${account} <span`); | |
| 294 | expect(at).toBeGreaterThan(-1); | |
| 295 | return main.slice(main.lastIndexOf("<li", at), main.indexOf("</li>", at)); | |
| 296 | }; | |
| 297 | ||
| 298 | test("a lapsed entry and one ending soon each say so with a badge and a glyph (#9792)", async () => { | |
| 299 | // Written straight to the table: the action refuses a date already past, and a lapse is what time does. | |
| 300 | const put = app.db.query( | |
| 301 | "INSERT INTO trusted_source (id, host, account, until, set_by, created_at) VALUES (?1, ?2, ?3, ?4, 'alice', ?5)", | |
| 302 | ); | |
| 303 | const now = Date.now(); | |
| 304 | put.run("t-lapsed", HOST, "gone-by", new Date(now - 86_400_000).toISOString(), new Date(now).toISOString()); | |
| 305 | put.run("t-soon", HOST, "nearly-out", new Date(now + 86_400_000).toISOString(), new Date(now).toISOString()); | |
| 306 | try { | |
| 307 | const main = await mainOf(app, alice); | |
| 308 | const lapsed = rowOf(main, "gone-by"); | |
| 309 | expect(lapsed).toContain('data-state="draft"'); | |
| 310 | expect(lapsed).toMatch(/class="badge"[^>]*>\s*<svg[^]*?Lapsed/); | |
| 311 | expect(lapsed).toContain("Waits for review since"); | |
| 312 | expect(lapsed).not.toContain("Ends soon"); | |
| 313 | const soon = rowOf(main, "nearly-out"); | |
| 314 | expect(soon).toContain('data-state="open"'); | |
| 315 | expect(soon).toMatch(/class="badge"[^>]*>\s*<svg[^]*?Ends soon/); | |
| 316 | expect(soon).not.toContain("Lapsed"); | |
| 317 | const steady = rowOf(main, "tdobbertin-espo"); | |
| 318 | expect(steady).toContain('data-state="open"'); | |
| 319 | expect(steady).toMatch(/class="badge"[^>]*>\s*<svg[^]*?Trusted/); | |
| 320 | expect(steady).not.toContain("Ends soon"); | |
| 321 | } finally { | |
| 322 | app.db.query("DELETE FROM trusted_source WHERE id IN ('t-lapsed', 't-soon')").run(); | |
| 323 | } | |
| 324 | }); | |
| 325 | ||
| 326 | test("with no one trusted, one plain sentence and no list (#9792)", async () => { | |
| 327 | const bare = await mkdtemp(join(tmpdir(), "forge-trusted-empty-")); | |
| 328 | const other = createApp({ dataDir: bare, port: 0, host: "forge.test" }); | |
| 329 | try { | |
| 330 | const made = await seedAdmin(other, { username: "bob", email: "[email protected]", password: "correct-horse-battery" }); | |
| 331 | if (!made.ok) throw new Error(made.error); | |
| 332 | const main = await mainOf(other, made.value); | |
| 333 | const from = main.indexOf("<h2>Trusted sources</h2>"); | |
| 334 | expect(from).toBeGreaterThan(-1); | |
| 335 | const block = main.slice(from, main.indexOf("</section>", from)); | |
| 336 | expect(block).toContain("No one is trusted, so every pull waits for review.</p>"); | |
| 337 | expect(block).not.toContain('id="trusted-list"'); | |
| 338 | expect(block).not.toContain("<li"); | |
| 339 | } finally { | |
| 340 | other.close(); | |
| 341 | await rm(bare, { recursive: true, force: true }); | |
| 342 | } | |
| 343 | }); | |
| 242 | 344 | }); |
Principles 1 and 8, box 1: this is two sentences and restates the Fold-in paragraph (lands when checks pass, no review). Make it one plain sentence, e.g. "No one is trusted, so every pull waits for review." Drop the second sentence.