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

Settings › Accounts speaks the home page's language #9772

Merged opened this

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

Merged

scout/forge:scout/9707-settings-accounts-speaks-the-home-page-s → main 4 commits fork

  • Merged. scout/9707-settings-accounts-speaks-the-home-page-s was merged into main as 0bcb087.

Review

Sign in to review this change.
5 files changed +205 −119
docs/design/areas/settings.md +5
@@ -89,3 +89,8 @@ Settings › Account (`src/web/pages/account.ts`: profile, password, tokens, SSH
8989 is a `block()` — a `.settings-block` with an `<h2>` — and the lists (tokens, SSH keys, signed-in browsers) are
9090 `.setting-list` rows: name and badge as the title, dates and fingerprint as the meta, Revoke/Remove/Sign out in the trail.
9191 An empty list is one muted sentence. No new class. Pinned by `test/account-rows.test.ts`.
92
93 Settings › Accounts (`src/web/pages/accounts.ts`) adds no component class. As on the home page it is headings over rows: the
94 accounts are `row()`s in `<ul class="list setting-list" id="accounts-list">` under `<h2>Who is on this forge</h2>`, the acts
95 (Sign-ins, Verify, Suspend, Remove) in the row's `after` slot; the mail, ownerless-organization, sign-in reach and
96 confirm/refusal notices are plain `<section class="settings-block">` blocks with an `<h2>`. `test/accounts-rows.test.ts` holds the shape.
src/web/pages/accounts.ts +79 −118
@@ -38,7 +38,7 @@ import {
3838 btn,
3939 card,
4040 confirmAction,
41 empty,
41 row,
4242 time,
4343 userLink,
4444 } from "../ui.ts";
@@ -71,7 +71,7 @@ function standing(a: AccountRow): Html {
7171 }
7272
7373 /** `sole`: the organizations this account is the only person owning. */
74 function row(a: AccountRow, sole: readonly string[]): Html {
74 function personRow(a: AccountRow, sole: readonly string[]): Html {
7575 const at = `/settings/accounts/${encodeURIComponent(a.username)}`;
7676 const how = [
7777 originOf(a),
@@ -84,26 +84,17 @@ function row(a: AccountRow, sole: readonly string[]): Html {
8484 ];
8585 // Who may be acted on: a person who is not an admin and is still here.
8686 const actionable = a.kind === "user" && !a.isAdmin && a.removedAt === null;
87 return html`<li class="row">
88 <div class="row-main">
89 ${userLink(a.username, { mark: markOf(a) })} ${standing(a)}
90 <div class="row-meta">
91 joined
92 ${time(a.createdAt)}${how.length > 0
93 ? html` · ${how.join(" · ")}`
94 : null}
95 · ${holds.join(" · ")}${sole.length > 0 && a.suspendedAt === null
96 ? html` · only owner of ${sole.join(", ")}`
97 : null}
98 </div>
99 ${sole.length > 0 && a.suspendedAt !== null
100 ? html`<div class="row-meta">
101 ${badge(`${sole.join(", ")}: no active owner`, { tone: "danger" })}
102 </div>`
103 : null}
104 </div>
105 ${actionable
106 ? html`<div class="row-actions">
87 return row({
88 title: html`${userLink(a.username, { mark: markOf(a) })} ${standing(a)}`,
89 meta: html`joined
90 ${time(a.createdAt)}${how.length > 0 ? html` · ${how.join(" · ")}` : null}
91 · ${holds.join(" · ")}${sole.length > 0 && a.suspendedAt === null
92 ? html` · only owner of ${sole.join(", ")}`
93 : null}${sole.length > 0 && a.suspendedAt !== null
94 ? html` · ${badge(`${sole.join(", ")}: no active owner`, { tone: "danger" })}`
95 : null}`,
96 ...(actionable
97 ? { after: html`
10798 ${btn("Sign-ins", { href: at, size: "sm", variant: "ghost" })}
10899 ${a.verified
109100 ? null
@@ -111,22 +102,14 @@ function row(a: AccountRow, sole: readonly string[]): Html {
111102 ${btn("Verify email", { type: "submit", size: "sm" })}
112103 </form>`}
113104 ${a.suspendedAt === null
114 ? confirmAction({
115 action: `${at}/suspend`,
116 verb: "Suspend",
117 size: "sm",
118 })
105 ? confirmAction({ action: `${at}/suspend`, verb: "Suspend", size: "sm" })
119106 : html`<form method="post" action="${at}/unsuspend">
120107 ${btn("Unsuspend", { type: "submit", size: "sm" })}
121108 </form>`}
122 ${confirmAction({
123 action: `${at}/remove`,
124 verb: "Remove",
125 size: "sm",
126 })}
127 </div>`
128 : null}
129 </li>`;
109 ${confirmAction({ action: `${at}/remove`, verb: "Remove", size: "sm" })}
110 ` }
111 : {}),
112 });
130113 }
131114
132115 // GET /settings/accounts
@@ -149,14 +132,14 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
149132 const mail = timePhase(ctx.request, "mail", () => mailConfigured(ctx.app));
150133 const noMail = mail
151134 ? null
152 : card(
153 html`<p>
154 Mail is not configured, so newcomers cannot verify their email
155 themselves. Verify them here, or set <code>MAIL_SMTP_URL</code> and
156 <code>MAIL_FROM</code> under <a href="/settings/secrets">Secrets</a>.
157 </p>`,
158 { tone: "attn" },
159 );
135 : html`<section class="settings-block" id="no-mail">
136 <h2>Mail is not configured</h2>
137 <p>
138 Newcomers cannot verify their email themselves. Verify them here, or
139 set <code>MAIL_SMTP_URL</code> and <code>MAIL_FROM</code> under
140 <a href="/settings/secrets">Secrets</a>.
141 </p>
142 </section>`;
160143 // An organization every owner of which is suspended or removed stays as
161144 // it is for its members; only the forge's owner can mend it.
162145 const orphans = timePhase(ctx.request, "orphans", () =>
@@ -165,8 +148,9 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
165148 const ownerless =
166149 orphans.length === 0
167150 ? null
168 : card(
169 html`<p>
151 : html`<section class="settings-block" id="ownerless">
152 <h2>No active owner</h2>
153 <p>
170154 ${orphans.map(
171155 (o, i) => html`${i > 0 ? ", " : ""}${userLink(o.username)}`,
172156 )}
@@ -178,18 +162,17 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
178162 >${o.username}'s Owners</a
179163 >`,
180164 )}.
181 </p>`,
182 { tone: "attn" },
183 );
165 </p>
166 </section>`;
184167 const reach = timePhase(ctx.request, "edge-only", () => signinReach(ctx.app.db));
185168 const stranded = reach.stranded;
186169 const edgeOnly =
187170 stranded.length === 0
188 ? card(
189 html`<p id="signin-reach">All ${reach.total} accounts can sign in without the platform edge.</p>`,
190 )
191 : card(
192 html`<p id="signin-reach">${reach.own} of ${reach.total} accounts can sign in without the platform edge.</p>
171 ? html`<section class="settings-block">
172 <p id="signin-reach">All ${reach.total} accounts can sign in without the platform edge.</p>
173 </section>`
174 : html`<section class="settings-block">
175 <h2 id="signin-reach">${reach.own} of ${reach.total} accounts can sign in without the platform edge.</h2>
193176 <p>
194177 ${stranded.length === 1 ? "This account signs" : "These accounts sign"}
195178 in only through the platform edge, so switching
@@ -202,9 +185,8 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
202185 ? " (admin)"
203186 : ""}`,
204187 )}.
205 </p>`,
206 { tone: "attn" },
207 );
188 </p>
189 </section>`;
208190 const sole = timePhase(ctx.request, "owned", () =>
209191 people.map((a) => {
210192 const u = getUserByName(ctx.app.db, a.username);
@@ -216,21 +198,13 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
216198 const rendering = performance.now();
217199 const response = shell(
218200 ctx,
219 html`${notice ?? null}${ownerless}${edgeOnly}${noMail}${card(
220 people.length === 0
221 ? empty({ icon: "users", title: "Nobody has joined yet." })
222 : html`<ul class="rows">
223 ${people.map((a, i) => row(a, sole[i] ?? []))}
224 </ul>`,
225 {
226 head: html`<h2>Who is on this forge</h2>
227 <p class="muted">
228 Newest first. You hear of each new account on your phone, if you set
229 a notify address. Suspending stops an account's sign-in and
230 everything its repositories run; removing deletes what it owns.
231 </p>`,
232 },
233 )}`,
201 html`${notice ?? null}${ownerless}${edgeOnly}${noMail}<section class="settings-block" id="accounts">
202 <h2>Who is on this forge</h2>
203 <p class="muted">Newest first.</p>
204 <ul class="list setting-list" id="accounts-list">
205 ${people.map((a, i) => personRow(a, sole[i] ?? []))}
206 </ul>
207 </section>`,
234208 status,
235209 );
236210 notePhase(ctx.request, "render", performance.now() - rendering);
@@ -244,8 +218,9 @@ const ownersTeam = (org: string): string =>
244218 /** Before suspending the last active owner of an organization, say so once. */
245219 function confirmSuspend(username: string, orgs: readonly string[]): Html {
246220 const at = `/settings/accounts/${encodeURIComponent(username)}`;
247 return card(
248 html`<p>
221 return html`<section class="settings-block" id="confirm-suspend">
222 <h2>Suspend the only active owner?</h2>
223 <p>
249224 <strong>${username}</strong> is the only active owner of
250225 ${orgs.join(", ")}. Suspended, nobody can administer
251226 ${orgs.length === 1 ? "it" : "them"} until they are unsuspended or
@@ -263,15 +238,15 @@ function confirmSuspend(username: string, orgs: readonly string[]): Html {
263238 ? null
264239 : btn("Add an owner first", { href: ownersTeam(orgs[0]), size: "sm" })}
265240 ${btn("Keep them", { href: "/settings/accounts", size: "sm", variant: "ghost" })}
266 </form>`,
267 { tone: "attn" },
268 );
241 </form>
242 </section>`;
269243 }
270244
271245 /** A removal refused for a sole-owned organization: the way through, once. */
272246 function refuseRemove(username: string): Html {
273 return card(
274 html`<p>
247 return html`<section class="settings-block" id="refuse-remove">
248 <h2>Not removed</h2>
249 <p>
275250 <strong>${username}</strong> is the only owner of an organization
276251 (named in their row). Make someone else an owner first, or remove the
277252 organization too after seeing everything that would go. Nothing was
@@ -283,9 +258,8 @@ function refuseRemove(username: string): Html {
283258 size: "sm",
284259 variant: "danger",
285260 })}
286 </div>`,
287 { tone: "attn" },
288 );
261 </div>
262 </section>`;
289263 }
290264
291265 // GET /settings/accounts/:username — where one person is signed in
@@ -441,8 +415,9 @@ export function retirePage(ctx: Ctx): Response {
441415 );
442416 return shell(
443417 ctx,
444 card(
445 html`<p>
418 html`<section class="settings-block" id="retire">
419 <h2>Remove ${username}</h2>
420 <p>
446421 ${orgs.length === 0 && own.length === 0
447422 ? html`Removing <strong>${username}</strong> removes only the account.`
448423 : html`Removing <strong>${username}</strong> also removes
@@ -460,36 +435,24 @@ export function retirePage(ctx: Ctx): Response {
460435 ${orgs.length === 0 && own.length === 0
461436 ? html`<p class="muted">They own no organization alone.</p>`
462437 : null}
463 <ul class="rows">
438 <ul class="list setting-list">
464439 ${own.length === 0
465440 ? null
466 : html`<li class="row">
467 <div class="row-main">
468 <strong>${username}</strong>
469 <div class="row-meta">${repoList(own)}</div>
470 </div>
471 </li>`}
472 ${orgs.map(
473 (o) => html`<li class="row">
474 <div class="row-main">
475 <strong>${o.org}</strong>
476 <div class="row-meta">
477 ${o.repos.length === 0 ? "no repositories" : repoList(o.repos)}
478 </div>
479 <div class="row-meta">
480 teams: ${o.teams.length === 0 ? "none" : o.teams.join(", ")}
481 </div>
482 </div>
483 </li>`,
441 : row({ title: username, meta: html`${repoList(own)}` })}
442 ${orgs.map((o) =>
443 row({
444 title: o.org,
445 meta: html`${o.repos.length === 0 ? "no repositories" : repoList(o.repos)}
446 · teams: ${o.teams.length === 0 ? "none" : o.teams.join(", ")}`,
447 }),
484448 )}
485449 </ul>
486450 <form method="post" action="${at}/retire" class="form-actions">
487451 <input type="hidden" name="confirm" value="1" />
488452 ${btn("Remove everything", { type: "submit", size: "sm", variant: "danger" })}
489453 ${btn("Keep them", { href: "/settings/accounts", size: "sm", variant: "ghost" })}
490 </form>`,
491 { tone: "attn" },
492 ),
454 </form>
455 </section>`,
493456 );
494457 }
495458
@@ -519,28 +482,26 @@ export async function retireAct(ctx: Ctx): Promise<Response> {
519482 if (failed !== null)
520483 return shell(
521484 ctx,
522 card(
523 html`<p>
524 <strong>${failed}</strong> would not go, so the removal stopped
525 there. ${removed.length === 0 ? "Nothing was removed yet." : "Removed so far:"}
485 html`<section class="settings-block">
486 <h2>Removal stopped</h2>
487 <p>
488 <strong>${failed}</strong> would not go. ${removed.length === 0 ? "Nothing was removed yet." : "Removed so far:"}
526489 Trying again finishes the job.
527490 </p>
528491 ${removed.length === 0 ? null : list}
529492 <form method="post" action="/settings/accounts/${encodeURIComponent(username)}/retire" class="form-actions">
530493 <input type="hidden" name="confirm" value="1" />
531494 ${btn("Try again", { type: "submit", size: "sm", variant: "danger" })} ${back}
532 </form>`,
533 { tone: "danger" },
534 ),
495 </form>
496 </section>`,
535497 500,
536498 );
537499 return shell(
538500 ctx,
539 card(
540 html`<p>
541 ${removed.length === 0 ? `${username} was already removed.` : "Removed:"}
542 </p>
543 ${removed.length === 0 ? null : list} ${back}`,
544 ),
501 html`<section class="settings-block">
502 <h2>${removed.length === 0 ? `${username} was already removed.` : "Removed"}</h2>
503 ${removed.length === 0 ? null : list}
504 <p>${back}</p>
505 </section>`,
545506 );
546507 }
src/web/style/components.ts +3
@@ -473,6 +473,9 @@ a.row:hover .row-title { color: var(--accent); }
473473 .list > li.has-after { display: flex; align-items: center; flex-wrap: wrap; }
474474 .list > li.has-after > .row, .list > li.has-after > details { flex: 1 1 0; min-width: 0; }
475475 .row-after { flex: none; padding: 0 14px 0 0; }
476 /* A settings row's acts (Accounts): always visible, wrapping, a full line under 640px. */
477 .setting-list > li.has-after > .row-after { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
478 @media (max-width: 640px) { .setting-list > li.has-after > .row-after { flex-basis: 100%; justify-content: flex-start; padding: 0 14px 10px; } }
476479 .list > li.has-after > .row-children { flex-basis: 100%; }
477480 .archived-list .row-title, .archived-list .row-meta { white-space: normal; overflow-wrap: anywhere; }
478481 @media (max-width: 640px) {
src/web/ui.ts +1 −1
@@ -1003,7 +1003,7 @@ export function row(o: RowOptions): Html {
10031003 </details>`
10041004 : o.href !== undefined
10051005 ? html`<a href="${o.href}" ${attrs}>${inner}</a>`
1006 : html`<div ${attrs}>${inner}</div>`}${o.after === undefined ? null : html`<span class="row-after">${o.after}</span>`}${o.children === undefined || o.children.length === 0
1006 : html`<div ${attrs}>${inner}</div>`}${o.after === undefined ? null : html`<div class="row-after">${o.after}</div>`}${o.children === undefined || o.children.length === 0
10071007 ? null
10081008 : html`<ul class="row-children">${[...o.children]}</ul>`}
10091009 </li>`;
test/accounts-rows.test.ts added +117
@@ -0,0 +1,117 @@
1 /** Settings › Accounts: headings over rows, no boxes (#9707). */
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 { createOrg } from "../src/core/orgs.ts";
9 import { createSession } from "../src/core/sessions.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { seedAdmin } from "./helpers/admin.ts";
12 import { expectWork, requestStatements } from "./helpers/work.ts";
13
14 let dataDir = "";
15 let app: App, servers: Servers;
16 let cookie = "";
17
18 beforeAll(async () => {
19 dataDir = await mkdtemp(join(tmpdir(), "forge-accounts-rows-"));
20 app = createApp({ dataDir, port: 0 });
21 servers = await serve(app);
22 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
23 if (!a.ok) throw new Error(a.error);
24 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bobby", email: "[email protected]", password: "correct-horse-battery" });
25 if (!b.ok) throw new Error(b.error);
26 cookie = `forge_session=${createSession(app.db, getUserByName(app.db, "alice")!.id).token}`;
27 });
28 afterAll(async () => {
29 servers.stop();
30 app.close();
31 await rm(dataDir, { recursive: true, force: true });
32 });
33
34 test("the accounts are rows under a heading, and no section is a card", async () => {
35 const page = await (await fetch(`${servers.url}/settings/accounts`, { headers: { cookie } })).text();
36 const main = page.slice(page.indexOf("<main"), page.indexOf("</main>"));
37 expect(main).toContain("<h2>Who is on this forge</h2>");
38 expect(main).toContain('<ul class="list setting-list" id="accounts-list">');
39 expect(main).toContain('class="row-title"');
40 for (const boxed of ['class="card', "card-head", "card-body", 'class="empty', 'class="rows"']) expect(main).not.toContain(boxed);
41 // the acts and their confirm step stay
42 expect(main).toContain("%2Fsettings%2Faccounts%2Fbobby%2Fsuspend");
43 expect(main).toContain("%2Fsettings%2Faccounts%2Fbobby%2Fremove");
44 });
45
46 const section = (page: string, id: string): string => {
47 const from = page.indexOf(`id="${id}"`);
48 expect(from).toBeGreaterThan(-1);
49 const start = page.lastIndexOf("<section", from);
50 return page.slice(start, page.indexOf("</section>", from));
51 };
52 const post = (path: string, body: Record<string, string> = {}) =>
53 fetch(`${servers.url}${path}`, {
54 method: "POST",
55 redirect: "manual",
56 headers: { cookie, origin: servers.url },
57 body: new URLSearchParams(body),
58 });
59 const unboxed = (html: string) => {
60 expect(html).toContain('<section class="settings-block"');
61 expect(html).toContain("<h2");
62 for (const boxed of ['class="card', "card-head", "card-body", 'class="empty']) expect(html).not.toContain(boxed);
63 };
64
65 test("the acts are plain forms and links in a div, not inside a span", async () => {
66 const page = await (await fetch(`${servers.url}/settings/accounts`, { headers: { cookie } })).text();
67 expect(page).toContain('<div class="row-after">');
68 expect(page).not.toContain('<span class="row-after">');
69 expect(page).not.toContain('class="row-actions"');
70 });
71
72 test("the mail notice is one headed section", async () => {
73 const page = await (await fetch(`${servers.url}/settings/accounts`, { headers: { cookie } })).text();
74 const s = section(page, "no-mail");
75 unboxed(s);
76 expect(s).not.toContain("Email verification");
77 });
78
79 test("the suspend confirm, the removal refusal, the retire page and the removed page are headed sections, not cards", async () => {
80 const bobby = getUserByName(app.db, "bobby")!;
81 const org = createOrg(app.db, "bobbyco", bobby.id);
82 if (!org.ok) throw new Error(org.error);
83
84 const suspend = await post("/settings/accounts/bobby/suspend");
85 expect(suspend.status).toBe(409);
86 const confirm = section(await suspend.text(), "confirm-suspend");
87 unboxed(confirm);
88 expect(confirm).toContain("Suspend anyway");
89
90 const remove = await post("/settings/accounts/bobby/remove");
91 expect(remove.status).toBe(409);
92 const refuse = section(await remove.text(), "refuse-remove");
93 unboxed(refuse);
94
95 const retire = await (await fetch(`${servers.url}/settings/accounts/bobby/retire`, { headers: { cookie } })).text();
96 const plan = section(retire, "retire");
97 unboxed(plan);
98 expect(plan).toContain("<h2>Remove bobby</h2>");
99 expect(plan).toContain("bobbyco");
100 expect(plan).toContain("Remove everything");
101
102 const done = await post("/settings/accounts/bobby/retire", { confirm: "1" });
103 expect(done.status).toBe(200);
104 const gone = await done.text();
105 const main = gone.slice(gone.indexOf("<main"), gone.indexOf("</main>"));
106 unboxed(main);
107 expect(main).toContain("<h2>Removed</h2>");
108 });
109
110 test("a non-admin still gets the 404, and the page costs a handful of statements", async () => {
111 const mallory = await createUser(app.db, { origin: "seed", trusted: true, username: "mallory", email: "[email protected]", password: "correct-horse-battery" });
112 if (!mallory.ok) throw new Error(mallory.error);
113 const theirs = `forge_session=${createSession(app.db, getUserByName(app.db, "mallory")!.id).token}`;
114 expect((await fetch(`${servers.url}/settings/accounts`, { headers: { cookie: theirs } })).status).toBe(404);
115 await fetch(`${servers.url}/settings/accounts`, { headers: { cookie } });
116 expectWork(await requestStatements(app.db, `${servers.url}/settings/accounts`, { headers: { cookie } }), { max: 25 });
117 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs