Public: anyone can read this; the crew can change it.
Settings › Accounts, who is on the forge, speaks the home page's language #9882
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9870-settings-accounts-who-is-on-the-forge-sp → main 5 commits fork 3 threads · 3 unresolved
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 | ||
| 150 | 150 | |
| 151 | 151 | `/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. |
| 152 | 152 | |
| 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 | ||
| 153 | 155 | 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`. |
| 154 | 156 | |
| 155 | 157 | 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"; | ||
| 36 | 36 | import { |
| 37 | 37 | badge, |
| 38 | 38 | btn, |
| 39 | card, | |
| 40 | 39 | confirmAction, |
| 41 | 40 | row, |
| 42 | 41 | time, |
| @@ -278,40 +277,30 @@ export function personSignInsPage(ctx: Ctx): Response { | ||
| 278 | 277 | const at = `/settings/accounts/${encodeURIComponent(user.username)}`; |
| 279 | 278 | return shell( |
| 280 | 279 | 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 | |
| 283 | 288 | ? 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 | }), | |
| 303 | 301 | )} |
| 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> | |
| 315 | 304 | ${grantCard(ctx, user, at)} |
| 316 | 305 | ${sessionsCard(sessions, at)}`, |
| 317 | 306 | ); |
src/web/pages/model.ts +9 −4
| @@ -101,11 +101,16 @@ export async function modelAdd(ctx: Ctx): Promise<Response> { | ||
| 101 | 101 | ); |
| 102 | 102 | } |
| 103 | 103 | |
| 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 | ||
| 104 | 109 | /** 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 { | |
| 106 | 111 | const ceiling = getAccountCeiling(ctx.app.db, user.id); |
| 107 | 112 | const left = dailyLeft(ctx.app.db, user.id, new Date()); |
| 108 | return card( | |
| 113 | return (plain ? settingsBlock : card)( | |
| 109 | 114 | html`<form class="stack" method="post" action="${at}/ceiling"> |
| 110 | 115 | <div class="field"> |
| 111 | 116 | <label for="f-daily">Daily ceiling, dollars</label> |
| @@ -136,8 +141,8 @@ export function ceilingCard(ctx: Ctx, user: User, at: string): Html { | ||
| 136 | 141 | /** The admin's card on a person's page: what they have, and the grant form. */ |
| 137 | 142 | export function grantCard(ctx: Ctx, user: User, at: string): Html { |
| 138 | 143 | 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( | |
| 141 | 146 | html`<form class="stack" method="post" action="${at}/model"> |
| 142 | 147 | <div class="field"> |
| 143 | 148 | <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 |
||
| 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 |
||
| 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 | }); |
Principles 6/7: grantCard and sessionsCard still render as
.cardboxes 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.