Public: anyone can read this; the crew can change it.
The Secrets card speaks the home page's language, at the forge, an organization and a repository #9723
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9718-the-secrets-card-speaks-the-home-page-s → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +110
−57
docs/design/areas/secrets.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # Secrets (Settings › Secrets, an organization's page, a repository's settings) | |
| 2 | ||
| 3 | `secretsCard()` in `src/web/pages/secrets.ts` adds no component class. It draws one block in three places: the forge's | |
| 4 | `/settings/secrets`, an organization's page and a repository's settings. As on the home page, it is a heading over rows: | |
| 5 | a `<section class="settings-block">` with an `<h2>`, one muted sentence (sealed values handed to a run by name; the narrowest place wins), a `.setting-list` of `row()`s (name, when it | |
| 6 | was set, when it was last opened, Remove), and the set form as an `.edit-row`. No card around it, and no icon in a | |
| 7 | row's lead. Values are write-only: the value field is a password input that is never filled. `test/secrets-card.test.ts` | |
| 8 | renders all three places and finds the same class names. Classes used are named in `docs/design/areas/settings.md`. |
src/web/pages/secrets.ts +38 −57
| @@ -23,7 +23,7 @@ import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | ||
| 23 | 23 | import { field, readForm } from "../form.ts"; |
| 24 | 24 | import { html, when, type Html } from "../html.ts"; |
| 25 | 25 | import { flashTo, page } from "../layout.ts"; |
| 26 | import { btn, card, icon, row, time } from "../ui.ts"; | |
| 26 | import { btn, row, time } from "../ui.ts"; | |
| 27 | 27 | import { accountSections } from "./origins.ts"; |
| 28 | 28 | |
| 29 | 29 | const ERRORS: Record<string, string> = { |
| @@ -45,12 +45,6 @@ function backTo(scope: string, ref: string): string { | ||
| 45 | 45 | return "/settings/secrets"; |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | const WHERE: Record<SecretScope, string> = { | |
| 49 | forge: "the whole forge", | |
| 50 | org: "every repository in this organization", | |
| 51 | repo: "this repository", | |
| 52 | }; | |
| 53 | ||
| 54 | 48 | /** The card: names, never values, and a form to set one. */ |
| 55 | 49 | export function secretsCard( |
| 56 | 50 | scope: SecretScope, |
| @@ -59,7 +53,6 @@ export function secretsCard( | ||
| 59 | 53 | ): Html { |
| 60 | 54 | const rows = names.map((s) => |
| 61 | 55 | row({ |
| 62 | lead: icon("key"), | |
| 63 | 56 | title: html`<span class="mono">${s.name}</span>`, |
| 64 | 57 | meta: html`set ${time(s.updatedAt)} · |
| 65 | 58 | ${ |
| @@ -83,55 +76,43 @@ export function secretsCard( | ||
| 83 | 76 | </form>`, |
| 84 | 77 | }), |
| 85 | 78 | ); |
| 86 | return card( | |
| 87 | html`<div class="card-body"> | |
| 88 | <p class="muted small"> | |
| 89 | What the forge itself needs and nobody should read back — an API | |
| 90 | token, a bucket key. Set one for ${WHERE[scope]}; it is sealed, shown | |
| 91 | to no one, and handed only to the run that asks for it by name. Where | |
| 92 | the same name is set more than once, the narrowest place wins: a | |
| 93 | repository over its organization over the forge. | |
| 94 | </p> | |
| 95 | </div> | |
| 96 | ${when( | |
| 97 | rows.length > 0, | |
| 98 | () => | |
| 99 | html`<ul class="list"> | |
| 100 | ${rows} | |
| 101 | </ul>`, | |
| 102 | )} | |
| 103 | <form class="edit-row" method="post" action="/settings/secrets"> | |
| 104 | <input type="hidden" name="scope" value="${scope}" /> | |
| 105 | <input type="hidden" name="ref" value="${ref}" /> | |
| 106 | <input | |
| 107 | class="input mono" | |
| 108 | type="text" | |
| 109 | name="name" | |
| 110 | placeholder="NAME" | |
| 111 | aria-label="Secret name" | |
| 112 | pattern="[A-Z][A-Z0-9_]*" | |
| 113 | maxlength="${String(NAME_MAX)}" | |
| 114 | autocomplete="off" | |
| 115 | spellcheck="false" | |
| 116 | required | |
| 117 | /> | |
| 118 | <input | |
| 119 | class="input" | |
| 120 | type="password" | |
| 121 | name="value" | |
| 122 | placeholder="Value" | |
| 123 | aria-label="Value" | |
| 124 | autocomplete="off" | |
| 125 | required | |
| 126 | /> | |
| 127 | ${btn("Seal", { variant: "primary", size: "sm", icon: "plus" })} | |
| 128 | </form>`, | |
| 129 | { | |
| 130 | head: html`${icon("key")} | |
| 131 | <h2>Secrets</h2>`, | |
| 132 | pad: false, | |
| 133 | }, | |
| 134 | ); | |
| 79 | return html`<section class="settings-block"> | |
| 80 | <h2>Secrets</h2> | |
| 81 | <p class="muted small">Sealed values the forge hands to a run by name; the narrowest place wins.</p> | |
| 82 | ${when( | |
| 83 | rows.length > 0, | |
| 84 | () => | |
| 85 | html`<ul class="list setting-list"> | |
| 86 | ${rows} | |
| 87 | </ul>`, | |
| 88 | )} | |
| 89 | <form class="edit-row mt" method="post" action="/settings/secrets"> | |
| 90 | <input type="hidden" name="scope" value="${scope}" /> | |
| 91 | <input type="hidden" name="ref" value="${ref}" /> | |
| 92 | <input | |
| 93 | class="input mono" | |
| 94 | type="text" | |
| 95 | name="name" | |
| 96 | placeholder="NAME" | |
| 97 | aria-label="Secret name" | |
| 98 | pattern="[A-Z][A-Z0-9_]*" | |
| 99 | maxlength="${String(NAME_MAX)}" | |
| 100 | autocomplete="off" | |
| 101 | spellcheck="false" | |
| 102 | required | |
| 103 | /> | |
| 104 | <input | |
| 105 | class="input" | |
| 106 | type="password" | |
| 107 | name="value" | |
| 108 | placeholder="Value" | |
| 109 | aria-label="Value" | |
| 110 | autocomplete="off" | |
| 111 | required | |
| 112 | /> | |
| 113 | ${btn("Seal", { variant: "primary", size: "sm", icon: "plus" })} | |
| 114 | </form> | |
| 115 | </section>`; | |
| 135 | 116 | } |
| 136 | 117 | |
| 137 | 118 | /** The card for a place, or nothing for anyone who is not a forge admin. */ |
test/secrets-card.test.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** The Secrets card is one card in three places, drawn as the home page draws a block (#9718). */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import type { SecretName, SecretScope } from "../src/core/secret_store.ts"; | |
| 4 | import { secretsCard } from "../src/web/pages/secrets.ts"; | |
| 5 | ||
| 6 | const names: SecretName[] = [ | |
| 7 | { name: "CLOUDFLARE_API_TOKEN", scope: "forge", scopeId: "", updatedAt: "2026-11-01T10:00:00.000Z", lastReadAt: null }, | |
| 8 | { name: "BUCKET_KEY", scope: "forge", scopeId: "", updatedAt: "2026-11-02T10:00:00.000Z", lastReadAt: "2026-11-03T10:00:00.000Z" }, | |
| 9 | ]; | |
| 10 | ||
| 11 | const places: [SecretScope, string][] = [ | |
| 12 | ["forge", ""], | |
| 13 | ["org", "acme"], | |
| 14 | ["repo", "acme/site"], | |
| 15 | ]; | |
| 16 | ||
| 17 | function classes(markup: string): string[] { | |
| 18 | const found = new Set<string>(); | |
| 19 | for (const m of markup.matchAll(/class="([^"]*)"/g)) for (const c of m[1]!.split(/\s+/)) if (c) found.add(c); | |
| 20 | return [...found].sort(); | |
| 21 | } | |
| 22 | ||
| 23 | test("the card has the same classes at the forge, an organization and a repository", () => { | |
| 24 | const drawn = places.map(([scope, ref]) => classes(String(secretsCard(scope, ref, names)))); | |
| 25 | expect(drawn[1]).toEqual(drawn[0]!); | |
| 26 | expect(drawn[2]).toEqual(drawn[0]!); | |
| 27 | }); | |
| 28 | ||
| 29 | test("the card is a heading over rows, not a boxed card with icons", () => { | |
| 30 | for (const [scope, ref] of places) { | |
| 31 | const markup = String(secretsCard(scope, ref, names)); | |
| 32 | expect(markup).toContain('<section class="settings-block">'); | |
| 33 | expect(markup).toContain("<h2>Secrets</h2>"); | |
| 34 | expect(markup).toContain('class="list setting-list"'); | |
| 35 | const found = classes(markup); | |
| 36 | expect(found).not.toContain("card"); | |
| 37 | expect(found).not.toContain("card-head"); | |
| 38 | expect(found).not.toContain("row-lead"); | |
| 39 | } | |
| 40 | }); | |
| 41 | ||
| 42 | test("each row keeps its name, when it was set and when it was last opened; the value field stays empty", () => { | |
| 43 | const markup = String(secretsCard("repo", "acme/site", names)); | |
| 44 | expect(markup).toContain("CLOUDFLARE_API_TOKEN"); | |
| 45 | expect(markup).toContain("never used"); | |
| 46 | expect(markup).toContain("last used"); | |
| 47 | expect(markup.match(/set <time/g)?.length).toBe(2); | |
| 48 | const value = /<input[^>]*name="value"[^>]*>/.exec(markup)?.[0] ?? ""; | |
| 49 | expect(value).toContain('type="password"'); | |
| 50 | expect(value).not.toContain("value="); | |
| 51 | expect(markup).toContain('action="/settings/secrets"'); | |
| 52 | expect(markup).toContain('action="/settings/secrets/delete"'); | |
| 53 | }); | |
| 54 | ||
| 55 | test("the card says what it is for in one sentence, the same in every place", () => { | |
| 56 | const intros = places.map(([scope, ref]) => { | |
| 57 | const markup = String(secretsCard(scope, ref, names)); | |
| 58 | const intro = /<h2>Secrets<\/h2>\s*<p class="muted small">([^<]*)<\/p>/.exec(markup)?.[1]?.trim() ?? ""; | |
| 59 | expect(intro.length).toBeGreaterThan(0); | |
| 60 | expect(intro.match(/[.!?](\s|$)/g)?.length).toBe(1); | |
| 61 | return intro; | |
| 62 | }); | |
| 63 | expect(new Set(intros).size).toBe(1); | |
| 64 | }); |
Principle 1 (one confident sentence; nothing explains itself twice) and 8 (plain words): this is a four-sentence paragraph that explains sealing, visibility, hand-off and precedence, and WHERE[scope] repeats what the place already says. Replace it with one muted sentence, e.g. "Sealed values the forge hands to a run by name; the narrowest place wins." Update the docs/design/areas/secrets.md line ("one muted sentence") to match, and add a test assertion that the intro is a single sentence.