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

Settings › Accounts, who is on the forge, speaks the home page's language #9882

Merged opened this

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

Merged

scout/forge:scout/9870-settings-accounts-who-is-on-the-forge-sp → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/9870-settings-accounts-who-is-on-the-forge-sp was merged into main as e14a27d.

Review

Sign in to review this change.
4 files changed +90 −37
docs/design/areas/settings.md +2
@@ -150,6 +150,8 @@ Settings › Model (`modelPage()` in `src/web/pages/model.ts`, #9800) adds no cl
150150
151151 `/organizations/new` is one muted sentence (what an organization is) over the single name field, whose hint says it becomes the URL; "first owner" is said once, in the landing flash. A team's page (`/:owner/-/teams/:team`) is three `<section class="settings-block">`s, each an `<h2>` — Members, Add a member, Delete this team (not on Owners) — over a `list setting-list` of `row()`s or a plain form. No card, no empty-state box (an empty team is one muted line), no new class. Every change stays a plain POST form or a confirm page, so it works with JavaScript off. `test/org-team-rows.test.ts` holds the shape.
152152
153 Settings › Accounts (`accountsPage()` in `src/web/pages/accounts.ts`, #9870) has no card: who is on the forge is a `<section class="settings-block" id="accounts">` under an `<h2>`, rows in `.list`, and a person's sign-ins page is the same (an `<h2>`, an `<h3>` for tokens, rows). No new class. Pinned by `test/accounts-language.test.ts`.
154
153155 Settings › Flags (`flagsPage()` in `src/web/pages/flags.ts`, #9872) has no card: one `<section class="settings-block">` under `<h2>Flags</h2>` holds the flags as `.list.setting-list` rows, or one plain sentence when none is declared. Pinned by `test/flags-language.test.ts`.
154156
155157 Settings › General (`settingsPage()` and `provenanceCard()` in `src/web/pages/settings.ts`, #9853) adds no class and holds no `.card`: About, Your team, On its own and Held files (`src/web/studio/crew.ts`, `file_holds.ts`), Branch and gates, Mirror, Show where this came from, Follow upstream and Followed sources (`upstream-follow.ts`), and Cut a wave and Archive are each a `<section class="settings-block">` with an `<h2>`, one plain sentence and the form. The crew-readiness notices (`crewReadiness()` in `src/web/pages/crew.ts`) are `.toast` rows with `data-tone="danger"`. Every form keeps its action, method, field names and ids. Pinned by `test/settings.test.ts`, which asserts the whole page has no `class="card`.
src/web/pages/accounts.ts +22 −33
@@ -36,7 +36,6 @@ import { flashTo, page } from "../layout.ts";
3636 import {
3737 badge,
3838 btn,
39 card,
4039 confirmAction,
4140 row,
4241 time,
@@ -278,40 +277,30 @@ export function personSignInsPage(ctx: Ctx): Response {
278277 const at = `/settings/accounts/${encodeURIComponent(user.username)}`;
279278 return shell(
280279 ctx,
281 html`${card(
282 tokens.length === 0
280 html`<section class="settings-block" id="tokens">
281 <h2>${userLink(user.username)}: where they are signed in</h2>
282 <p class="muted">
283 Revoking a token or signing out a browser ends it at once; the person
284 can sign in again. To stop them signing in, suspend the account.
285 </p>
286 <h3>Access tokens</h3>
287 ${tokens.length === 0
283288 ? html`<p class="muted">No access tokens.</p>`
284 : html`<ul class="rows">
285 ${tokens.map(
286 (t) =>
287 html`<li class="row">
288 <div class="row-main">
289 <strong>${t.name}</strong> ${badge(t.scope)}
290 <div class="row-meta">
291 made ${time(t.createdAt)} · last used
292 ${t.lastUsedAt ? time(t.lastUsedAt) : "never"}
293 </div>
294 </div>
295 <div class="row-actions">
296 ${confirmAction({
297 action: `${at}/tokens/${t.id}/delete`,
298 verb: "Revoke",
299 size: "sm",
300 })}
301 </div>
302 </li>`,
289 : html`<ul class="list setting-list">
290 ${tokens.map((t) =>
291 row({
292 title: html`${t.name} ${badge(t.scope)}`,
293 meta: html`made ${time(t.createdAt)} · last used
294 ${t.lastUsedAt ? time(t.lastUsedAt) : "never"}`,
295 after: confirmAction({
296 action: `${at}/tokens/${t.id}/delete`,
297 verb: "Revoke",
298 size: "sm",
299 }),
300 }),
303301 )}
304 </ul>`,
305 {
306 head: html`<h2>${userLink(user.username)}: where they are signed in</h2>
307 <p class="muted">
308 Revoking a token or signing out a browser ends it at once; the
309 person can sign in again. To stop them signing in, suspend the
310 account.
311 </p>
312 <h3>Access tokens</h3>`,
313 },
314 )}
302 </ul>`}
303 </section>
315304 ${grantCard(ctx, user, at)}

Principles 6/7: grantCard and sessionsCard still render as .card boxes directly under the new settings-block, so this page is half block, half box. Convert both (in model.ts and account.ts) to <section class="settings-block"> with an <h2> and rows in .list setting-list, keeping their forms and ids, and keep any other caller working.

316305 ${sessionsCard(sessions, at)}`,
317306 );
src/web/pages/model.ts +9 −4
@@ -101,11 +101,16 @@ export async function modelAdd(ctx: Ctx): Promise<Response> {
101101 );
102102 }
103103
104 /** A heading over its content, no box: `card` with a head, minus the box. */
105 function settingsBlock(body: Html, opts: { head: Html }): Html {
106 return html`<section class="settings-block">${opts.head}${body}</section>`;
107 }
108
104109 /** The admin's card for an account's spend ceilings, on a person's or an organization's page. */
105 export function ceilingCard(ctx: Ctx, user: User, at: string): Html {
110 export function ceilingCard(ctx: Ctx, user: User, at: string, plain = false): Html {
106111 const ceiling = getAccountCeiling(ctx.app.db, user.id);
107112 const left = dailyLeft(ctx.app.db, user.id, new Date());
108 return card(
113 return (plain ? settingsBlock : card)(
109114 html`<form class="stack" method="post" action="${at}/ceiling">
110115 <div class="field">
111116 <label for="f-daily">Daily ceiling, dollars</label>
@@ -136,8 +141,8 @@ export function ceilingCard(ctx: Ctx, user: User, at: string): Html {
136141 /** The admin's card on a person's page: what they have, and the grant form. */
137142 export function grantCard(ctx: Ctx, user: User, at: string): Html {
138143 const grant = getModelGrant(ctx.app.db, user.id);
139 const ceilings = ceilingCard(ctx, user, at);
140 const grantForm = card(
144 const ceilings = ceilingCard(ctx, user, at, true);
145 const grantForm = settingsBlock(
141146 html`<form class="stack" method="post" action="${at}/model">
142147 <div class="field">
143148 <label for="f-grant">Grant, dollars a month</label>
test/accounts-language.test.ts added +57
@@ -0,0 +1,57 @@
1 /** Settings › Accounts speaks the home page's language: a block, not a card (#9870). */
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 { createUser, getUserByName } from "../src/core/users.ts";
8 import { createSession } from "../src/core/sessions.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { seedAdmin } from "./helpers/admin.ts";
11
12 let dataDir = "";
13 let app: App, servers: Servers;
14 let admin = "", plain = "";
15
16 beforeAll(async () => {
17 dataDir = await mkdtemp(join(tmpdir(), "forge-accounts-lang-"));
18 app = createApp({ dataDir, port: 0 });
19 servers = await serve(app);
20 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
21 if (!a.ok) throw new Error(a.error);
22 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bobby", email: "[email protected]", password: "correct-horse-battery" });
23 if (!b.ok) throw new Error(b.error);
24 admin = `forge_session=${createSession(app.db, getUserByName(app.db, "alice")!.id).token}`;
25 plain = `forge_session=${createSession(app.db, getUserByName(app.db, "bobby")!.id).token}`;
26 });
27 afterAll(async () => {
28 servers.stop();
29 app.close();
30 await rm(dataDir, { recursive: true, force: true });
31 });
32
33 const main = (page: string) => page.slice(page.indexOf("<main"), page.indexOf("</main>"));
34
35 test("a person's sign-ins page is blocks all the way down, no card", async () => {
36 const m = main(await (await fetch(`${servers.url}/settings/accounts/bobby`, { headers: { cookie: admin } })).text());
37 expect(m).not.toContain('class="card');
38 expect(m).toContain('<section class="settings-block" id="tokens">');
outdated

Assert expect(m).not.toContain('class="card') here too. This is the page that still has cards, and the assertion would fail until grantCard and sessionsCard are converted.

39 expect(m).toContain("No access tokens.");
40 // the grant, ceiling and browsers sections are still there, forms intact
41 expect(m).toContain("Model budget");
42 expect(m).toContain('action="/settings/accounts/bobby/model"');
43 expect(m).toContain('action="/settings/accounts/bobby/ceiling"');
44 expect(m).toContain('id="f-grant"');
45 expect(m).toContain("Signed-in browsers");
46 expect((m.match(/class="settings-block"/g) ?? []).length).toBeGreaterThanOrEqual(4);
outdated

A source-grep for card( tests the text of the file, not the behaviour. Drop it once the rendered-page assertions cover the sign-ins page.

47 });
48
49 test("the accounts list keeps its confirm links", async () => {
50 const m = main(await (await fetch(`${servers.url}/settings/accounts`, { headers: { cookie: admin } })).text());
51 expect(m).toContain("/-/confirm?to=%2Fsettings%2Faccounts%2Fbobby%2Fsuspend");
52 });
53
54 test("a non-admin gets the one 404", async () => {
55 const r = await fetch(`${servers.url}/settings/accounts`, { headers: { cookie: plain } });
56 expect(r.status).toBe(404);
57 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs