Public: anyone can read this; the crew can change it.
Settings › Account speaks the home page's language #9734
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9706-settings-account-speaks-the-home-page-s → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 82 | 82 | the egress note when cells only report, then `.settings-block` sections — Capacity (the `.setting-list` of settings |
| 83 | 83 | rows), Changes, Errors and refused calls, Egress connections, Self-swap. No new class. Pinned by |
| 84 | 84 | `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"; | ||
| 62 | 62 | import { |
| 63 | 63 | badge, |
| 64 | 64 | btn, |
| 65 | card, | |
| 66 | 65 | confirmAction, |
| 67 | 66 | copyField, |
| 68 | empty, | |
| 67 | row, | |
| 69 | 68 | selectField, |
| 70 | 69 | terminalCommand, |
| 71 | 70 | time, |
| @@ -75,6 +74,14 @@ import { | ||
| 75 | 74 | import { everydayLine } from "./everyday.ts"; |
| 76 | 75 | import { passkeyJs } from "../passkey-client.ts"; |
| 77 | 76 | |
| 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 | ||
| 78 | 85 | type Section = "profile" | "password" | "tokens" | "keys" | "passkeys"; |
| 79 | 86 | |
| 80 | 87 | // The section list lives beside the newest page that joins it, so adding one |
| @@ -135,7 +142,8 @@ const refusal = (message: string | undefined): Html => | ||
| 135 | 142 | |
| 136 | 143 | function notifyView(ctx: Ctx, error?: string): Html { |
| 137 | 144 | const host = notifyHost(notifyUrlOf(ctx.app.db, ctx.auth.user!.id)); |
| 138 | return card( | |
| 145 | return block( | |
| 146 | "Phone notifications", | |
| 139 | 147 | html`<form method="post" action="/settings/notify"> |
| 140 | 148 | ${uiField({ |
| 141 | 149 | label: "Notify URL", |
| @@ -153,7 +161,6 @@ function notifyView(ctx: Ctx, error?: string): Html { | ||
| 153 | 161 | </div> |
| 154 | 162 | ${refusal(error)} |
| 155 | 163 | </form>`, |
| 156 | { head: html`<h3>Phone notifications</h3>` }, | |
| 157 | 164 | ); |
| 158 | 165 | } |
| 159 | 166 | |
| @@ -163,7 +170,8 @@ function edgeLinkView(ctx: Ctx): Html { | ||
| 163 | 170 | if (token === undefined) return html``; |
| 164 | 171 | const linked = linkedLogin(ctx.app.db, ctx.auth.user!.id); |
| 165 | 172 | const carried = edgeIdentity(ctx.request, token); |
| 166 | return card( | |
| 173 | return block( | |
| 174 | "open-platform.sh", | |
| 167 | 175 | linked !== undefined |
| 168 | 176 | ? html`<p>open-platform.sh: linked as <strong>${linked}</strong></p> |
| 169 | 177 | ${confirmAction({ action: "/settings/edge/unlink", verb: "Unlink" })}` |
| @@ -173,7 +181,6 @@ function edgeLinkView(ctx: Ctx): Html { | ||
| 173 | 181 | : html`<form method="post" action="/settings/edge/link"> |
| 174 | 182 | ${btn(`Link ${carried}`, { type: "submit" })} |
| 175 | 183 | </form>`}`, |
| 176 | { head: html`<h3>open-platform.sh</h3>` }, | |
| 177 | 184 | ); |
| 178 | 185 | } |
| 179 | 186 | |
| @@ -183,7 +190,8 @@ function parentLinkView(ctx: Ctx): Html { | ||
| 183 | 190 | if (issuer === undefined) return html``; |
| 184 | 191 | const host = new URL(issuer).host; |
| 185 | 192 | const linked = linkedParent(ctx.app.db, ctx.auth.user!.id, issuer) !== undefined; |
| 186 | return card( | |
| 193 | return block( | |
| 194 | host, | |
| 187 | 195 | linked |
| 188 | 196 | ? html`<p>${host}: linked</p> |
| 189 | 197 | ${confirmAction({ action: "/settings/parent/unlink", verb: "Unlink" })}` |
| @@ -191,7 +199,6 @@ function parentLinkView(ctx: Ctx): Html { | ||
| 191 | 199 | <form method="post" action="/settings/parent/link"> |
| 192 | 200 | ${btn(`Link ${host}`, { type: "submit" })} |
| 193 | 201 | </form>`, |
| 194 | { head: html`<h3>${host}</h3>` }, | |
| 195 | 202 | ); |
| 196 | 203 | } |
| 197 | 204 | |
| @@ -246,21 +253,21 @@ export async function edgeUnlink(ctx: Ctx): Promise<Response> { | ||
| 246 | 253 | function unverifiedNotice(ctx: Ctx): Html { |
| 247 | 254 | const user = ctx.auth.user!; |
| 248 | 255 | 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"> | |
| 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>`; | |
| 264 | 271 | } |
| 265 | 272 | |
| 266 | 273 | // POST /settings/email/verify |
| @@ -287,20 +294,21 @@ export async function resendVerifyLink(ctx: Ctx): Promise<Response> { | ||
| 287 | 294 | function roleView(ctx: Ctx): Html { |
| 288 | 295 | const role = roleOf(ctx.app.db, ctx.auth.user!); |
| 289 | 296 | const row = rowOf(role); |
| 290 | return card( | |
| 297 | return block( | |
| 298 | "What you can do here", | |
| 291 | 299 | html`<p>You are ${badge(role)} here.</p> |
| 292 | 300 | <ul class="role-can"> |
| 293 | 301 | ${(Object.keys(ABILITIES) as Ability[]).map( |
| 294 | 302 | (a) => html`<li>${ABILITIES[a]}: <strong>${row[a] ? "yes" : "no"}</strong></li>`, |
| 295 | 303 | )} |
| 296 | 304 | </ul>`, |
| 297 | { head: html`<h3>What you can do here</h3>` }, | |
| 298 | 305 | ); |
| 299 | 306 | } |
| 300 | 307 | |
| 301 | 308 | function profileView(ctx: Ctx, values: ProfileForm): Html { |
| 302 | 309 | const user = ctx.auth.user!; |
| 303 | return html`${unverifiedNotice(ctx)}${roleView(ctx)}${card( | |
| 310 | return html`${unverifiedNotice(ctx)}${roleView(ctx)}${block( | |
| 311 | "Public profile", | |
| 304 | 312 | html`<form method="post" action="/settings"> |
| 305 | 313 | <div class="field"> |
| 306 | 314 | <label for="f-username">Username</label> |
| @@ -356,7 +364,6 @@ function profileView(ctx: Ctx, values: ProfileForm): Html { | ||
| 356 | 364 | </div> |
| 357 | 365 | ${refusal(values.errors?.form)} |
| 358 | 366 | </form>`, |
| 359 | { head: html`<h3>Public profile</h3>` }, | |
| 360 | 367 | )}`; |
| 361 | 368 | } |
| 362 | 369 | |
| @@ -457,7 +464,8 @@ function passwordView(error?: { | ||
| 457 | 464 | field: "current" | "next"; |
| 458 | 465 | text: string; |
| 459 | 466 | }): Html { |
| 460 | return card( | |
| 467 | return block( | |
| 468 | "Change password", | |
| 461 | 469 | html`<form method="post" action="/settings/password"> |
| 462 | 470 | ${uiField({ |
| 463 | 471 | label: "Current password", |
| @@ -488,7 +496,6 @@ function passwordView(error?: { | ||
| 488 | 496 | ${btn("Change password", { variant: "primary" })} |
| 489 | 497 | </div> |
| 490 | 498 | </form>`, |
| 491 | { head: html`<h3>Change password</h3>` }, | |
| 492 | 499 | ); |
| 493 | 500 | } |
| 494 | 501 | |
| @@ -548,42 +555,23 @@ export function sessionsCard( | ||
| 548 | 555 | sessions: readonly SessionInfo[], |
| 549 | 556 | base: string, |
| 550 | 557 | ): Html { |
| 551 | return card( | |
| 558 | return block( | |
| 559 | "Signed-in browsers", | |
| 552 | 560 | sessions.length === 0 |
| 553 | 561 | ? 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>`, | |
| 587 | 575 | ); |
| 588 | 576 | } |
| 589 | 577 | |
| @@ -594,7 +582,8 @@ export function sessionsCard( | ||
| 594 | 582 | export function connectCard(ctx: Ctx, token: string | undefined): Html { |
| 595 | 583 | const url = (ctx.app.config.url ?? ctx.url.origin).replace(/\/+$/, ""); |
| 596 | 584 | const env = `FORGE_URL=${url} FORGE_TOKEN=${token ?? "<token>"}`; |
| 597 | return card( | |
| 585 | return block( | |
| 586 | "Connect your harness", | |
| 598 | 587 | html`<p class="muted small mb"> |
| 599 | 588 | ${token ? "Your new token is filled in." : "Make a token below, then replace <token>."} |
| 600 | 589 | See plugins/claude-code and plugins/opencode. |
| @@ -609,7 +598,6 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html { | ||
| 609 | 598 | "OpenCode command", |
| 610 | 599 | "Copy command", |
| 611 | 600 | )}`, |
| 612 | { head: html`<h3>Connect your harness</h3>` }, | |
| 613 | 601 | ); |
| 614 | 602 | } |
| 615 | 603 | |
| @@ -618,63 +606,29 @@ function tokensView(ctx: Ctx): Html { | ||
| 618 | 606 | const sessions = timePhase(ctx.request, "sessions", () => |
| 619 | 607 | listSessions(ctx.app.db, ctx.auth.user!.id, cookieValue(ctx.request, "forge_session")), |
| 620 | 608 | ); |
| 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>`; | |
| 621 | 623 | return html`<p class="muted small mb"> |
| 622 | 624 | Use a token as the password when pushing over HTTPS, or as a |
| 623 | 625 | <span class="mono">Bearer</span> token for the API. Your account password |
| 624 | 626 | works for git too — tokens are for scoped or automated access. |
| 625 | 627 | </p> |
| 626 | 628 | ${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", | |
| 678 | 632 | html`<form method="post" action="/settings/tokens"> |
| 679 | 633 | ${uiField({ |
| 680 | 634 | label: "Token name", |
| @@ -709,7 +663,6 @@ function tokensView(ctx: Ctx): Html { | ||
| 709 | 663 | ${btn("Generate token", { variant: "primary" })} |
| 710 | 664 | </div> |
| 711 | 665 | </form>`, |
| 712 | { head: html`<h3>New token</h3>` }, | |
| 713 | 666 | )} |
| 714 | 667 | ${sessionsCard(sessions, "/settings")}`; |
| 715 | 668 | } |
| @@ -809,53 +762,22 @@ function keysView( | ||
| 809 | 762 | >~/.ssh/id_ed25519.pub</span |
| 810 | 763 | >) — ed25519, ecdsa, or rsa (2048 bits or more). |
| 811 | 764 | </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", | |
| 859 | 781 | html`<form method="post" action="/settings/keys"> |
| 860 | 782 | ${uiField({ |
| 861 | 783 | label: "Name", |
| @@ -878,7 +800,6 @@ function keysView( | ||
| 878 | 800 | </div> |
| 879 | 801 | ${refusal(values.error)} |
| 880 | 802 | </form>`, |
| 881 | { head: html`<h3>New SSH key</h3>` }, | |
| 882 | 803 | )}`; |
| 883 | 804 | } |
| 884 | 805 |
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> { | ||
| 83 | 83 | if (!minted.ok || minted.value.token === undefined) throw new Error("no token"); |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | /** The table row that carries a token's name. */ | |
| 86 | /** The list row that carries a token's name. */ | |
| 87 | 87 | 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>`)); | |
| 89 | 89 | if (row === undefined) throw new Error(`no row for ${tokenName}`); |
| 90 | 90 | return row; |
| 91 | 91 | } |
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.