Public: anyone can read this; the crew can change it.

Settings › Account speaks the home page's language #9734

Merged opened this

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

Merged

mason/forge:mason/9706-settings-account-speaks-the-home-page-s → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/9706-settings-account-speaks-the-home-page-s was merged into main as eb8dfff.

Review

Sign in to review this change.
4 files changed +210 −167
docs/design/areas/settings.md +7
@@ -82,3 +82,10 @@ Settings › Capacity (`capacityPage()` in `src/web/pages/capacity.ts`, #9709) h
8282 the egress note when cells only report, then `.settings-block` sections — Capacity (the `.setting-list` of settings
8383 rows), Changes, Errors and refused calls, Egress connections, Self-swap. No new class. Pinned by
8484 `test/capacity-rows.test.ts`.
85
86 ## Account (#9706)
87
88 Settings › Account (`src/web/pages/account.ts`: profile, password, tokens, SSH keys) has no card and no table. Each part
89 is a `block()` — a `.settings-block` with an `<h2>` — and the lists (tokens, SSH keys, signed-in browsers) are
90 `.setting-list` rows: name and badge as the title, dates and fingerprint as the meta, Revoke/Remove/Sign out in the trail.
91 An empty list is one muted sentence. No new class. Pinned by `test/account-rows.test.ts`.
src/web/pages/account.ts +86 −165
@@ -62,10 +62,9 @@ import { readFlash } from "../flash.ts";
6262 import {
6363 badge,
6464 btn,
65 card,
6665 confirmAction,
6766 copyField,
68 empty,
67 row,
6968 selectField,
7069 terminalCommand,
7170 time,
@@ -75,6 +74,14 @@ import {
7574 import { everydayLine } from "./everyday.ts";
7675 import { passkeyJs } from "../passkey-client.ts";
7776
77 /** A titled block of a settings page: a heading over its content, no box. */
78 const block = (title: string, body: Html, intro?: string): Html =>
79 html`<section class="settings-block">
80 <h2>${title}</h2>
81 ${intro === undefined ? null : html`<p class="muted small">${intro}</p>`}
82 ${body}
83 </section>`;
84
7885 type Section = "profile" | "password" | "tokens" | "keys" | "passkeys";
7986
8087 // The section list lives beside the newest page that joins it, so adding one
@@ -135,7 +142,8 @@ const refusal = (message: string | undefined): Html =>
135142
136143 function notifyView(ctx: Ctx, error?: string): Html {
137144 const host = notifyHost(notifyUrlOf(ctx.app.db, ctx.auth.user!.id));
138 return card(
145 return block(
146 "Phone notifications",
139147 html`<form method="post" action="/settings/notify">
140148 ${uiField({
141149 label: "Notify URL",
@@ -153,7 +161,6 @@ function notifyView(ctx: Ctx, error?: string): Html {
153161 </div>
154162 ${refusal(error)}
155163 </form>`,
156 { head: html`<h3>Phone notifications</h3>` },
157164 );
158165 }
159166
@@ -163,7 +170,8 @@ function edgeLinkView(ctx: Ctx): Html {
163170 if (token === undefined) return html``;
164171 const linked = linkedLogin(ctx.app.db, ctx.auth.user!.id);
165172 const carried = edgeIdentity(ctx.request, token);
166 return card(
173 return block(
174 "open-platform.sh",
167175 linked !== undefined
168176 ? html`<p>open-platform.sh: linked as <strong>${linked}</strong></p>
169177 ${confirmAction({ action: "/settings/edge/unlink", verb: "Unlink" })}`
@@ -173,7 +181,6 @@ function edgeLinkView(ctx: Ctx): Html {
173181 : html`<form method="post" action="/settings/edge/link">
174182 ${btn(`Link ${carried}`, { type: "submit" })}
175183 </form>`}`,
176 { head: html`<h3>open-platform.sh</h3>` },
177184 );
178185 }
179186
@@ -183,7 +190,8 @@ function parentLinkView(ctx: Ctx): Html {
183190 if (issuer === undefined) return html``;
184191 const host = new URL(issuer).host;
185192 const linked = linkedParent(ctx.app.db, ctx.auth.user!.id, issuer) !== undefined;
186 return card(
193 return block(
194 host,
187195 linked
188196 ? html`<p>${host}: linked</p>
189197 ${confirmAction({ action: "/settings/parent/unlink", verb: "Unlink" })}`
@@ -191,7 +199,6 @@ function parentLinkView(ctx: Ctx): Html {
191199 <form method="post" action="/settings/parent/link">
192200 ${btn(`Link ${host}`, { type: "submit" })}
193201 </form>`,
194 { head: html`<h3>${host}</h3>` },
195202 );
196203 }
197204
@@ -246,21 +253,21 @@ export async function edgeUnlink(ctx: Ctx): Promise<Response> {
246253 function unverifiedNotice(ctx: Ctx): Html {
247254 const user = ctx.auth.user!;
248255 if (emailVerified(ctx.app.db, user.id)) return html``;
249 return card(
250 html`<p>
251 Your email is not verified yet. Until it is, you can read and talk,
252 but not make repositories, apps or runs.
253 ${isStandInEmail(user.email)
254 ? html`Add your email below and follow the link we send to it.`
255 : html`Follow the link we sent to <strong>${user.email}</strong>, or ask for another.`}
256 </p>
257 ${isStandInEmail(user.email)
258 ? html``
259 : html`<form method="post" action="/settings/email/verify">
260 ${btn("Send another link", { type: "submit" })}
261 </form>`}`,
262 { tone: "attn" },
263 );
256 const standIn = isStandInEmail(user.email);
257 return html`<section class="settings-block">
outdated

Principle 5/6 intent: data-tone="attn" has no CSS for .settings-block (only .stat/.pill/.dot define it), so the notice now looks like any other text and the unverified warning loses its emphasis. Drop the attribute or add the rule and document it in docs/design/areas/settings.md. Also this hand-rolls a section instead of using block(); the h2-less form is fine, but the indentation of the body is left over from the card and should be tidied.

258 <p>
259 Your email is not verified yet. Until it is, you can read and talk,
260 but not make repositories, apps or runs.
261 ${standIn
262 ? html`Add your email below and follow the link we send to it.`
263 : html`Follow the link we sent to <strong>${user.email}</strong>, or ask for another.`}
264 </p>
265 ${standIn
266 ? html``
267 : html`<form method="post" action="/settings/email/verify">
268 ${btn("Send another link", { type: "submit" })}
269 </form>`}
270 </section>`;
264271 }
265272
266273 // POST /settings/email/verify
@@ -287,20 +294,21 @@ export async function resendVerifyLink(ctx: Ctx): Promise<Response> {
287294 function roleView(ctx: Ctx): Html {
288295 const role = roleOf(ctx.app.db, ctx.auth.user!);
289296 const row = rowOf(role);
290 return card(
297 return block(
298 "What you can do here",
291299 html`<p>You are ${badge(role)} here.</p>
292300 <ul class="role-can">
293301 ${(Object.keys(ABILITIES) as Ability[]).map(
294302 (a) => html`<li>${ABILITIES[a]}: <strong>${row[a] ? "yes" : "no"}</strong></li>`,
295303 )}
296304 </ul>`,
297 { head: html`<h3>What you can do here</h3>` },
298305 );
299306 }
300307
301308 function profileView(ctx: Ctx, values: ProfileForm): Html {
302309 const user = ctx.auth.user!;
303 return html`${unverifiedNotice(ctx)}${roleView(ctx)}${card(
310 return html`${unverifiedNotice(ctx)}${roleView(ctx)}${block(
311 "Public profile",
304312 html`<form method="post" action="/settings">
305313 <div class="field">
306314 <label for="f-username">Username</label>
@@ -356,7 +364,6 @@ function profileView(ctx: Ctx, values: ProfileForm): Html {
356364 </div>
357365 ${refusal(values.errors?.form)}
358366 </form>`,
359 { head: html`<h3>Public profile</h3>` },
360367 )}`;
361368 }
362369
@@ -457,7 +464,8 @@ function passwordView(error?: {
457464 field: "current" | "next";
458465 text: string;
459466 }): Html {
460 return card(
467 return block(
468 "Change password",
461469 html`<form method="post" action="/settings/password">
462470 ${uiField({
463471 label: "Current password",
@@ -488,7 +496,6 @@ function passwordView(error?: {
488496 ${btn("Change password", { variant: "primary" })}
489497 </div>
490498 </form>`,
491 { head: html`<h3>Change password</h3>` },
492499 );
493500 }
494501
@@ -548,42 +555,23 @@ export function sessionsCard(
548555 sessions: readonly SessionInfo[],
549556 base: string,
550557 ): Html {
551 return card(
558 return block(
559 "Signed-in browsers",
552560 sessions.length === 0
553561 ? html`<p class="muted">Not signed in on any browser.</p>`
554 : html`<div class="table-scroll">
555 <table class="table">
556 <thead>
557 <tr>
558 <th>Signed in</th>
559 <th>Last seen</th>
560 <th></th>
561 </tr>
562 </thead>
563 <tbody>
564 ${sessions.map(
565 (s) =>
566 html`<tr>
567 <td class="muted">
568 ${time(s.createdAt)}
569 ${s.current ? badge("This browser") : null}
570 </td>
571 <td class="muted">
572 ${s.lastSeenAt ? time(s.lastSeenAt) : "not since"}
573 </td>
574 <td>
575 ${confirmAction({
576 action: `${base}/sessions/${s.id}/delete`,
577 verb: "Sign out",
578 size: "sm",
579 })}
580 </td>
581 </tr>`,
582 )}
583 </tbody>
584 </table>
585 </div>`,
586 { pad: false, head: html`<h3>Signed-in browsers</h3>` },
562 : html`<ul class="list setting-list">
563 ${sessions.map((x) =>
564 row({
565 title: html`${time(x.createdAt)} ${x.current ? badge("This browser") : null}`,
566 meta: html`last seen ${x.lastSeenAt ? time(x.lastSeenAt) : "not since"}`,
567 trail: html`${confirmAction({
568 action: `${base}/sessions/${x.id}/delete`,
569 verb: "Sign out",
570 size: "sm",
571 })}`,
572 }),
573 )}
574 </ul>`,
587575 );
588576 }
589577
@@ -594,7 +582,8 @@ export function sessionsCard(
594582 export function connectCard(ctx: Ctx, token: string | undefined): Html {
595583 const url = (ctx.app.config.url ?? ctx.url.origin).replace(/\/+$/, "");
596584 const env = `FORGE_URL=${url} FORGE_TOKEN=${token ?? "<token>"}`;
597 return card(
585 return block(
586 "Connect your harness",
598587 html`<p class="muted small mb">
599588 ${token ? "Your new token is filled in." : "Make a token below, then replace <token>."}
600589 See plugins/claude-code and plugins/opencode.
@@ -609,7 +598,6 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html {
609598 "OpenCode command",
610599 "Copy command",
611600 )}`,
612 { head: html`<h3>Connect your harness</h3>` },
613601 );
614602 }
615603
@@ -618,63 +606,29 @@ function tokensView(ctx: Ctx): Html {
618606 const sessions = timePhase(ctx.request, "sessions", () =>
619607 listSessions(ctx.app.db, ctx.auth.user!.id, cookieValue(ctx.request, "forge_session")),
620608 );
609 const list = tokens.length === 0
610 ? html`<p class="muted">No tokens yet. Mint one when a script, a CI job, or another machine needs to act as you.</p>`
611 : html`<ul class="list setting-list">
612 ${tokens.map((t) =>
613 row({
614 title: html`<strong>${t.name}</strong> ${badge(t.scope)}`,
615 meta: html`created ${time(t.createdAt)} · last used ${t.lastUsedAt ? time(t.lastUsedAt) : "never"}${when(
616 t.oauthClient,
617 (client) => html`<span data-oauth-client> · ${client} · Signed in through OAuth; renews itself until you delete it</span>`,
618 )}`,
619 trail: confirmAction({ action: `/settings/tokens/${t.id}/delete`, verb: "Revoke", size: "sm" }),
620 }),
621 )}
622 </ul>`;
621623 return html`<p class="muted small mb">
622624 Use a token as the password when pushing over HTTPS, or as a
623625 <span class="mono">Bearer</span> token for the API. Your account password
624626 works for git too — tokens are for scoped or automated access.
625627 </p>
626628 ${connectCard(ctx, readFlash(ctx.request)?.code)}
627 ${tokens.length === 0
628 ? empty({
629 icon: "key",
630 title: "No tokens yet",
631 note: "Mint one when a script, a CI job, or another machine needs to act as you.",
632 })
633 : card(
634 html`<div class="table-scroll">
635 <table class="table">
636 <thead>
637 <tr>
638 <th>Name</th>
639 <th>Scope</th>
640 <th>Created</th>
641 <th>Last used</th>
642 <th></th>
643 </tr>
644 </thead>
645 <tbody>
646 ${tokens.map(
647 (t) =>
648 html`<tr>
649 <td>
650 <strong>${t.name}</strong>
651 ${when(
652 t.oauthClient,
653 (client) => html`<div class="muted small" data-oauth-client>
654 ${client} · Signed in through OAuth; renews itself until you delete it
655 </div>`,
656 )}
657 </td>
658 <td>${badge(t.scope)}</td>
659 <td class="muted">${time(t.createdAt)}</td>
660 <td class="muted">
661 ${t.lastUsedAt ? time(t.lastUsedAt) : "never"}
662 </td>
663 <td>
664 ${confirmAction({
665 action: `/settings/tokens/${t.id}/delete`,
666 verb: "Revoke",
667 size: "sm",
668 })}
669 </td>
670 </tr>`,
671 )}
672 </tbody>
673 </table>
674 </div>`,
675 { pad: false },
676 )}
677 ${card(
629 ${block("Tokens", list)}
630 ${block(
631 "New token",
678632 html`<form method="post" action="/settings/tokens">
679633 ${uiField({
680634 label: "Token name",
@@ -709,7 +663,6 @@ function tokensView(ctx: Ctx): Html {
709663 ${btn("Generate token", { variant: "primary" })}
710664 </div>
711665 </form>`,
712 { head: html`<h3>New token</h3>` },
713666 )}
714667 ${sessionsCard(sessions, "/settings")}`;
715668 }
@@ -809,53 +762,22 @@ function keysView(
809762 >~/.ssh/id_ed25519.pub</span
810763 >) — ed25519, ecdsa, or rsa (2048 bits or more).
811764 </p>
812 ${keys.length === 0
813 ? empty({
814 icon: "lock",
815 title: "No keys yet",
816 note: "Add one and git over SSH stops asking for a password.",
817 })
818 : card(
819 html`<div class="table-scroll">
820 <table class="table">
821 <thead>
822 <tr>
823 <th>Name</th>
824 <th>Fingerprint</th>
825 <th>Added</th>
826 <th>Last used</th>
827 <th></th>
828 </tr>
829 </thead>
830 <tbody>
831 ${keys.map(
832 (k) =>
833 html`<tr>
834 <td>
835 <strong>${k.name}</strong> ${badge(k.algo, {
836 variant: "secondary",
837 })}
838 </td>
839 <td class="mono small">${k.fingerprint}</td>
840 <td class="muted">${time(k.createdAt)}</td>
841 <td class="muted">
842 ${k.lastUsedAt ? time(k.lastUsedAt) : "never"}
843 </td>
844 <td>
845 ${confirmAction({
846 action: `/settings/keys/${k.id}/delete`,
847 verb: "Remove",
848 size: "sm",
849 })}
850 </td>
851 </tr>`,
852 )}
853 </tbody>
854 </table>
855 </div>`,
856 { pad: false },
857 )}
858 ${card(
765 ${block(
766 "SSH keys",
767 keys.length === 0
768 ? html`<p class="muted">No keys yet. Add one and git over SSH stops asking for a password.</p>`
769 : html`<ul class="list setting-list">
770 ${keys.map((k) =>
771 row({
772 title: html`<strong>${k.name}</strong> ${badge(k.algo, { variant: "secondary" })}`,
773 meta: html`<span class="mono">${k.fingerprint}</span> · added ${time(k.createdAt)} · last used ${k.lastUsedAt ? time(k.lastUsedAt) : "never"}`,
774 trail: confirmAction({ action: `/settings/keys/${k.id}/delete`, verb: "Remove", size: "sm" }),
775 }),
776 )}
777 </ul>`,
778 )}
779 ${block(
780 "New SSH key",
859781 html`<form method="post" action="/settings/keys">
860782 ${uiField({
861783 label: "Name",
@@ -878,7 +800,6 @@ function keysView(
878800 </div>
879801 ${refusal(values.error)}
880802 </form>`,
881 { head: html`<h3>New SSH key</h3>` },
882803 )}`;
883804 }
884805
test/account-rows.test.ts added +115
@@ -0,0 +1,115 @@
1 /** Settings › Account is rows under headings, not cards, and its tokens and keys are lists (#9706). */
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 { serve, type Servers } from "../src/http/server.ts";
8 import { getUserByName } from "../src/core/users.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { sessionsCard } from "../src/web/pages/account.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 let dir = "";
16 let app: App, servers: Servers, base = "", cookie = "";
17
18 const KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl laptop";
19
20 beforeAll(async () => {
21 dir = await mkdtemp(join(tmpdir(), "forge-acctrows-"));
22 app = createApp({ dataDir: dir, port: 0 });
23 servers = await serve(app);
24 base = servers.url;
25 const signup = await fetch(`${base}/signup`, {
26 method: "POST",
27 body: new URLSearchParams({ username: "ada", email: "[email protected]", password: "account-password" }),
28 redirect: "manual",
29 });
30 cookie = (signup.headers.get("set-cookie") ?? "").split(";")[0]!;
31 const user = getUserByName(app.db, "ada")!;
32 createToken(app.db, user.id, "laptop", "rw");
33 await fetch(`${base}/settings/keys`, {
34 method: "POST",
35 headers: { cookie, origin: base },
36 body: new URLSearchParams({ name: "work", key: KEY }),
37 redirect: "manual",
38 });
39 });
40
41 afterAll(async () => {
42 servers.stop();
43 app.close();
44 await rm(dir, { recursive: true, force: true });
45 });
46
47 const main = async (path: string): Promise<string> => {
48 const res = await fetch(`${base}${path}`, { headers: { cookie } });
49 expect(res.status).toBe(200);
50 const body = await res.text();
51 return body.slice(body.indexOf('class="settings-main"'));
52 };
53
54 test("no account page is a card or holds a table", async () => {
55 for (const path of ["/settings", "/settings/password", "/settings/tokens", "/settings/keys"]) {
56 const m = await main(path);
57 expect(m).not.toMatch(/class="card[\s"]/);
outdated

Only the populated lists are tested. Add a case with no tokens/keys asserting the single muted sentence (no empty() panel), and one OAuth-issued token asserting the data-oauth-client line sits inside its row.

58 expect(m).not.toContain("card-head");
59 expect(m).not.toContain("<table");
60 }
61 });
62
63 test("tokens are rows under a Tokens heading, each with Revoke", async () => {
64 const m = await main("/settings/tokens");
65 expect(m).toContain('<section class="settings-block">');
66 const list = m.slice(m.indexOf("<h2>Tokens</h2>"));
67 expect(list).toMatch(/^<h2>Tokens<\/h2>\s*<ul class="list setting-list">/);
68 const item = list.slice(0, list.indexOf("</ul>"));
69 expect(item).toContain("<strong>laptop</strong>");
70 expect(item).toMatch(/settings%2Ftokens%2F[^"]+%2Fdelete/);
71 expect(m).toContain('action="/settings/tokens"');
72 });
73
74 test("keys are rows under an SSH keys heading, each with its Remove link", async () => {
75 const m = await main("/settings/keys");
76 const list = m.slice(m.indexOf("<h2>SSH keys</h2>"));
77 expect(list).toMatch(/^<h2>SSH keys<\/h2>\s*<ul class="list setting-list">/);
78 const item = list.slice(0, list.indexOf("</ul>"));
79 expect(item).toContain("<strong>work</strong>");
80 expect(item).toMatch(/settings%2Fkeys%2F[^"]+%2Fdelete/);
81 expect(m).toContain('action="/settings/keys"');
82 });
83
84 test("empty lists are one muted sentence, not a panel", async () => {
85 const other = await fetch(`${base}/signup`, {
86 method: "POST",
87 body: new URLSearchParams({ username: "bob", email: "[email protected]", password: "account-password" }),
88 redirect: "manual",
89 });
90 const c = (other.headers.get("set-cookie") ?? "").split(";")[0]!;
91 const get = async (path: string) => (await (await fetch(`${base}${path}`, { headers: { cookie: c } })).text());
92 const tokens = await get("/settings/tokens");
93 expect(tokens).toContain('<p class="muted">No tokens yet.');
94 expect(tokens).not.toContain("empty-title");
95 const keys = await get("/settings/keys");
96 expect(keys).toContain('<p class="muted">No keys yet.');
97 expect(keys).not.toContain("empty-title");
98 const card = String(sessionsCard([], "/settings"));
99 expect(card).toContain('<p class="muted">Not signed in on any browser.</p>');
100 expect(card).not.toContain("<ul");
101 });
102
103 test("an OAuth-issued token names its client inside its row", async () => {
104 const user = getUserByName(app.db, "ada")!;
105 const { id } = createToken(app.db, user.id, "claude", "rw");
106 app.db.query("INSERT INTO oauth_client (id, name, redirect_uris, created_at) VALUES ('c1', 'Claude Code', '[]', ?1)").run(new Date().toISOString());
107 app.db.query("INSERT INTO oauth_token (token_id, client_id) VALUES (?1, 'c1')").run(id);
108 const m = await main("/settings/tokens");
109 const list = m.slice(m.indexOf('<ul class="list setting-list">'));
110 const item = list.slice(0, list.indexOf("</ul>"));
111 const li = item.split("<li").find((x) => x.includes("<strong>claude</strong>"))!;
112 expect(li).toContain("data-oauth-client");
113 expect(li).toContain("Claude Code");
114 expect(li).toContain("renews itself");
115 });
test/tokens-oauth-client.test.ts +2 −2
@@ -83,9 +83,9 @@ async function exchange(clientName: string, tokenName: string): Promise<void> {
8383 if (!minted.ok || minted.value.token === undefined) throw new Error("no token");
8484 }
8585
86 /** The table row that carries a token's name. */
86 /** The list row that carries a token's name. */
8787 function rowOf(page: string, tokenName: string): string {
88 const row = page.split("<tr>").find((r) => r.includes(`<strong>${tokenName}</strong>`));
88 const row = page.split("<li").find((r) => r.includes(`<strong>${tokenName}</strong>`));
8989 if (row === undefined) throw new Error(`no row for ${tokenName}`);
9090 return row;
9191 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs