Public: anyone can read this; the crew can change it.
Account form errors draw the shared danger component, and the page-local .error-note class is gone #9101
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9085-account-form-errors-draw-the-shared-dang → main 2 commits fork
4 files
changed +101
−14
docs/design/sections/5d-page-local-classes.md −1
| @@ -20,7 +20,6 @@ the same line (#4888). | ||
| 20 | 20 | |
| 21 | 21 | - **`style/base.ts`** — the page shell (topbar, main, footer) and the layout and text utilities every page leans on (all of them now named in docs/design/areas/shell.md and visibility.md). |
| 22 | 22 | - **`style/components.ts`** — parts of the §5 components that the table does not spell out — the palette's list, the menu's items, a dialog's head and foot, comments — and a few small helpers beside them: |
| 23 | - `.error-note` | |
| 24 | 23 | - **`style/content.ts`, page furniture** — what frames a page's content: repo heads, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, lineage: |
| 25 | 24 | - `.repo-actions` |
| 26 | 25 | - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs and checks (a pull's look is §5's **look**). |
src/web/pages/account.ts +18 −6
| @@ -70,6 +70,7 @@ import { | ||
| 70 | 70 | terminalCommand, |
| 71 | 71 | time, |
| 72 | 72 | field as uiField, |
| 73 | icon, | |
| 73 | 74 | } from "../ui.ts"; |
| 74 | 75 | import { everydayLine } from "./everyday.ts"; |
| 75 | 76 | import { passkeyJs } from "../passkey-client.ts"; |
| @@ -118,11 +119,24 @@ const currentProfile = (user: User): ProfileForm => ({ | ||
| 118 | 119 | commitEmail: user.commitEmail, |
| 119 | 120 | }); |
| 120 | 121 | |
| 122 | /** | |
| 123 | * A refused submit, in the shared danger toast (as a failed mirror is): under | |
| 124 | * the form's actions, where the toast's own top margin sits, so nothing above | |
| 125 | * it moves when it appears. | |
| 126 | */ | |
| 127 | const refusal = (message: string | undefined): Html => | |
| 128 | when( | |
| 129 | message, | |
| 130 | (m) => html`<div class="toast" data-tone="danger" role="alert"> | |
| 131 | ${icon("alert")} | |
| 132 | <div class="toast-body">${m}</div> | |
| 133 | </div>`, | |
| 134 | ); | |
| 135 | ||
| 121 | 136 | function notifyView(ctx: Ctx, error?: string): Html { |
| 122 | 137 | const host = notifyHost(notifyUrlOf(ctx.app.db, ctx.auth.user!.id)); |
| 123 | 138 | return card( |
| 124 | 139 | html`<form method="post" action="/settings/notify"> |
| 125 | ${when(error, (m) => html`<p class="error-note">${m}</p>`)} | |
| 126 | 140 | ${uiField({ |
| 127 | 141 | label: "Notify URL", |
| 128 | 142 | name: "url", |
| @@ -137,6 +151,7 @@ function notifyView(ctx: Ctx, error?: string): Html { | ||
| 137 | 151 | <div class="form-actions"> |
| 138 | 152 | ${btn("Save notify URL", { variant: "primary" })} |
| 139 | 153 | </div> |
| 154 | ${refusal(error)} | |
| 140 | 155 | </form>`, |
| 141 | 156 | { head: html`<h3>Phone notifications</h3>` }, |
| 142 | 157 | ); |
| @@ -287,7 +302,6 @@ function profileView(ctx: Ctx, values: ProfileForm): Html { | ||
| 287 | 302 | const user = ctx.auth.user!; |
| 288 | 303 | return html`${unverifiedNotice(ctx)}${roleView(ctx)}${card( |
| 289 | 304 | html`<form method="post" action="/settings"> |
| 290 | ${when(values.errors?.form, (m) => html`<p class="error-note">${m}</p>`)} | |
| 291 | 305 | <div class="field"> |
| 292 | 306 | <label for="f-username">Username</label> |
| 293 | 307 | <input |
| @@ -340,6 +354,7 @@ function profileView(ctx: Ctx, values: ProfileForm): Html { | ||
| 340 | 354 | <div class="form-actions"> |
| 341 | 355 | ${btn("Save profile", { variant: "primary" })} |
| 342 | 356 | </div> |
| 357 | ${refusal(values.errors?.form)} | |
| 343 | 358 | </form>`, |
| 344 | 359 | { head: html`<h3>Public profile</h3>` }, |
| 345 | 360 | )}`; |
| @@ -842,10 +857,6 @@ function keysView( | ||
| 842 | 857 | )} |
| 843 | 858 | ${card( |
| 844 | 859 | html`<form method="post" action="/settings/keys"> |
| 845 | ${when( | |
| 846 | values.error, | |
| 847 | (message) => html`<p class="error-note">${message}</p>`, | |
| 848 | )} | |
| 849 | 860 | ${uiField({ |
| 850 | 861 | label: "Name", |
| 851 | 862 | name: "name", |
| @@ -865,6 +876,7 @@ function keysView( | ||
| 865 | 876 | <div class="form-actions"> |
| 866 | 877 | ${btn("Add key", { variant: "primary" })} |
| 867 | 878 | </div> |
| 879 | ${refusal(values.error)} | |
| 868 | 880 | </form>`, |
| 869 | 881 | { head: html`<h3>New SSH key</h3>` }, |
| 870 | 882 | )}`; |
src/web/style/components.ts −7
| @@ -141,13 +141,6 @@ fieldset.field > .check + .check { margin-top: 8px; } | ||
| 141 | 141 | .field-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; } |
| 142 | 142 | .field-row .field { flex: 1; min-width: 160px; margin-bottom: 0; } |
| 143 | 143 | .form-actions { margin-top: 16px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } |
| 144 | .error-note { | |
| 145 | display: block; color: var(--danger); font-size: 13px; font-weight: 500; | |
| 146 | padding: 8px 12px; margin: 0 0 12px; | |
| 147 | border: 1px solid color-mix(in srgb, var(--danger) 32%, transparent); | |
| 148 | background: color-mix(in srgb, var(--danger) 10%, transparent); | |
| 149 | border-radius: var(--radius-sm); | |
| 150 | } | |
| 151 | 144 | |
| 152 | 145 | /* ---- stat tile ---- fixed heights: a changing value shifts nothing */ |
| 153 | 146 | .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; } |
test/account-refusals.test.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** | |
| 2 | * Settings' refused submits draw the shared danger toast with role="alert", | |
| 3 | * not the retired page-local `.error-note` (#9085): the notify URL, the | |
| 4 | * profile's form-level refusal and a new SSH key. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | ||
| 14 | describe("account refusals", () => { | |
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let cookie: string; | |
| 19 | ||
| 20 | beforeAll(async () => { | |
| 21 | dataDir = await mkdtemp(join(tmpdir(), "forge-account-refusals-")); | |
| 22 | app = createApp({ dataDir, port: 0 }); | |
| 23 | servers = await serve(app); | |
| 24 | const made = await createUser(app.db, { | |
| 25 | origin: "seed", | |
| 26 | trusted: true, | |
| 27 | username: "alice", | |
| 28 | email: "[email protected]", | |
| 29 | password: "a-long-passphrase", | |
| 30 | adminIfFirst: true, | |
| 31 | }); | |
| 32 | if (!made.ok) throw new Error(made.error); | |
| 33 | const signin = await fetch(`${servers.url}/signin`, { | |
| 34 | method: "POST", | |
| 35 | body: new URLSearchParams({ username: "alice", password: "a-long-passphrase" }), | |
| 36 | redirect: "manual", | |
| 37 | }); | |
| 38 | cookie = signin.headers.get("set-cookie")!.split(";")[0]!; | |
| 39 | }); | |
| 40 | afterAll(async () => { | |
| 41 | servers.stop(); | |
| 42 | await rm(dataDir, { recursive: true, force: true }); | |
| 43 | }); | |
| 44 | ||
| 45 | const refuse = async (path: string, form: Record<string, string>) => { | |
| 46 | const res = await fetch(`${servers.url}${path}`, { | |
| 47 | method: "POST", | |
| 48 | headers: { cookie }, | |
| 49 | body: new URLSearchParams(form), | |
| 50 | redirect: "manual", | |
| 51 | }); | |
| 52 | expect(res.status).toBe(422); | |
| 53 | return res.text(); | |
| 54 | }; | |
| 55 | ||
| 56 | /** The words sit inside a danger toast that announces itself. */ | |
| 57 | const inAlert = (page: string, words: string) => { | |
| 58 | const toast = page.match( | |
| 59 | /<div class="toast" data-tone="danger" role="alert">([\s\S]*?)<\/div>\s*<\/div>/, | |
| 60 | ); | |
| 61 | expect(toast).not.toBeNull(); | |
| 62 | expect(toast![1]).toContain(words); | |
| 63 | expect(page).not.toContain("error-note"); | |
| 64 | }; | |
| 65 | ||
| 66 | test("a refused notify URL", async () => { | |
| 67 | inAlert(await refuse("/settings/notify", { url: "http://ntfy.example.test/t" }), "Only https:"); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a refused profile with nothing a field is at fault for", async () => { | |
| 71 | const page = await refuse("/settings", { | |
| 72 | displayName: "Alice", | |
| 73 | email: "[email protected]", | |
| 74 | commitEmail: "<b>bogus</b>", | |
| 75 | }); | |
| 76 | inAlert(page, "Your profile could not be saved."); | |
| 77 | expect(page).not.toContain("<b>bogus</b>"); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a refused SSH key", async () => { | |
| 81 | inAlert(await refuse("/settings/keys", { name: "bad", key: "nonsense" }), "supported public key"); | |
| 82 | }); | |
| 83 | }); |
new above
offline