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

Merged opened this

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

Merged

scout/forge:scout/9085-account-form-errors-draw-the-shared-dang → main 2 commits fork

  • Merged. scout/9085-account-form-errors-draw-the-shared-dang was merged into main as fc910d9.

Review

Sign in to review this change.
4 files changed +101 −14
docs/design/sections/5d-page-local-classes.md −1
@@ -20,7 +20,6 @@ the same line (#4888).
2020
2121 - **`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).
2222 - **`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`
2423 - **`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:
2524 - `.repo-actions`
2625 - **`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 {
7070 terminalCommand,
7171 time,
7272 field as uiField,
73 icon,
7374 } from "../ui.ts";
7475 import { everydayLine } from "./everyday.ts";
7576 import { passkeyJs } from "../passkey-client.ts";
@@ -118,11 +119,24 @@ const currentProfile = (user: User): ProfileForm => ({
118119 commitEmail: user.commitEmail,
119120 });
120121
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
121136 function notifyView(ctx: Ctx, error?: string): Html {
122137 const host = notifyHost(notifyUrlOf(ctx.app.db, ctx.auth.user!.id));
123138 return card(
124139 html`<form method="post" action="/settings/notify">
125 ${when(error, (m) => html`<p class="error-note">${m}</p>`)}
126140 ${uiField({
127141 label: "Notify URL",
128142 name: "url",
@@ -137,6 +151,7 @@ function notifyView(ctx: Ctx, error?: string): Html {
137151 <div class="form-actions">
138152 ${btn("Save notify URL", { variant: "primary" })}
139153 </div>
154 ${refusal(error)}
140155 </form>`,
141156 { head: html`<h3>Phone notifications</h3>` },
142157 );
@@ -287,7 +302,6 @@ function profileView(ctx: Ctx, values: ProfileForm): Html {
287302 const user = ctx.auth.user!;
288303 return html`${unverifiedNotice(ctx)}${roleView(ctx)}${card(
289304 html`<form method="post" action="/settings">
290 ${when(values.errors?.form, (m) => html`<p class="error-note">${m}</p>`)}
291305 <div class="field">
292306 <label for="f-username">Username</label>
293307 <input
@@ -340,6 +354,7 @@ function profileView(ctx: Ctx, values: ProfileForm): Html {
340354 <div class="form-actions">
341355 ${btn("Save profile", { variant: "primary" })}
342356 </div>
357 ${refusal(values.errors?.form)}
343358 </form>`,
344359 { head: html`<h3>Public profile</h3>` },
345360 )}`;
@@ -842,10 +857,6 @@ function keysView(
842857 )}
843858 ${card(
844859 html`<form method="post" action="/settings/keys">
845 ${when(
846 values.error,
847 (message) => html`<p class="error-note">${message}</p>`,
848 )}
849860 ${uiField({
850861 label: "Name",
851862 name: "name",
@@ -865,6 +876,7 @@ function keysView(
865876 <div class="form-actions">
866877 ${btn("Add key", { variant: "primary" })}
867878 </div>
879 ${refusal(values.error)}
868880 </form>`,
869881 { head: html`<h3>New SSH key</h3>` },
870882 )}`;
src/web/style/components.ts −7
@@ -141,13 +141,6 @@ fieldset.field > .check + .check { margin-top: 8px; }
141141 .field-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; }
142142 .field-row .field { flex: 1; min-width: 160px; margin-bottom: 0; }
143143 .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 }
151144
152145 /* ---- stat tile ---- fixed heights: a changing value shifts nothing */
153146 .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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs