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

The terminal shows your profile and sets your display name with `n` and email with `e`, in the account page's words #8144

Merged opened this

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

Merged

torque/forge:torque/8001-the-terminal-shows-your-profile-and-sets → main 3 commits fork 3 threads · 3 unresolved

  • Merged. torque/8001-the-terminal-shows-your-profile-and-sets was merged into main as cd0cc2f.

Review

Sign in to review this change.
8 files changed +352 −10
README.md +2
@@ -530,3 +530,5 @@ The web changelog's days and its "shipped today" count follow the reader's clock
530530 A repository admin cuts a wave from two doors, both through `cutWaveFor` (`src/actions/cut_wave.ts`): Settings → *Cut a wave*, and `c` on the terminal's waves screen, which says how many pulls would go in, asks `cut wave N? y/n` (`y` and `n` choose outright) and shows the draft pull's number (Enter opens it). Each opens a draft pull listing the pulls merged since the last wave; nothing merged, an earlier wave that does not parse, or a viewer who is not an admin is refused in the action's words, writing nothing.
531531
532532 Cmd-K finds a repository by its name first (exact, then prefix; your own ahead); a repository with an app has an "Open the app of …" option under it, and Cmd/Ctrl+Enter on the repository opens the app (`docs/features/cmd-k-repositories.md`).
533
534 In the terminal, the palette's **Your profile** (any signed-in person, never an agent; a guest is not offered it) shows username, display name and email; `n` sets the display name and `e` the email through `updateProfile`, the action behind Settings → Account. Refusals read as the page's do (`src/actions/profile_words.ts`, including one shared fallback for a refusal with no words of its own), and a changed email says a confirmation link was sent to it and that it is unverified until followed.
src/actions/profile_words.ts added +33
@@ -0,0 +1,33 @@
1 /**
2 * What a person is told when `updateProfile` refuses: one sentence per error,
3 * shared by Settings › Account and the terminal's profile screen so the two
4 * say the same thing.
5 */
6
7 import type { updateProfile } from "./users.ts";
8
9 type ProfileError = Extract<ReturnType<typeof updateProfile>, { ok: false }>["error"];
10
11 /** The refusals a person can fix by writing something else. */
12 export const PROFILE_REFUSALS: Partial<Record<ProfileError, string>> = {
13 email_taken: "Another account already uses that address.",
14 invalid_email: "That doesn't look like an email address.",
15 display_name_reserved: "That name belongs to the crew, the owner or the forge.",
16 display_name_mixed_script:
17 "Write it in one alphabet: letters from different scripts can pass for other names.",
18 };
19
20 /** The form field a refusal belongs to. */
21 export const PROFILE_REFUSAL_FIELD: Partial<Record<ProfileError, "displayName" | "email">> = {
22 email_taken: "email",
23 invalid_email: "email",
24 display_name_reserved: "displayName",
25 display_name_mixed_script: "displayName",
26 };
27
28 /** Any refusal not named above (a failed sign-in, a vanished account): nothing the person typed is at fault. */
29 export const PROFILE_FALLBACK = "Your profile could not be saved. Sign in again and try once more.";
30
31 /** After a new address is saved: the link goes to it, and the old proof no longer stands. */
32 export const emailSavedWords = (email: string): string =>
33 `Email saved. A confirmation link was sent to ${email}; it is not verified until you follow it.`;
src/tui/screens/entries.ts +2
@@ -27,6 +27,7 @@ import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
2727 import { entry as orgTeams } from "./orgteams.ts";
2828 import { entry as owner } from "./owner.ts";
2929 import { entry as people } from "./people.ts";
30 import { entry as profile } from "./profile.ts";
3031 import { entry as quotas } from "./quotas.ts";
3132 import { entry as resilience } from "./resilience.ts";
3233 import { entry as scorecard } from "./scorecard.ts";
@@ -83,6 +84,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
8384 front,
8485 owner,
8586 forges,
87 profile,
8688 ];
8789
8890 /** Put each entry the session is offered into `commands`, after the command it names or at the end. */
src/tui/screens/profile.ts added +89
@@ -0,0 +1,89 @@
1 /**
2 * Your profile, in the terminal: username, display name and email, with `n`
3 * and `e` to change the last two through `updateProfile` — the action behind
4 * Settings › Account, refusing in the same words. A new email starts
5 * verification exactly as it does on the web.
6 */
7
8 import { emailSavedWords, PROFILE_FALLBACK, PROFILE_REFUSALS } from "../../actions/profile_words.ts";
9 import { updateProfile } from "../../actions/users.ts";
10 import { isStandInEmail } from "../../core/users.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import type { Session } from "../session.ts";
14 import { style } from "../term/style.ts";
15 import { truncate } from "../term/width.ts";
16 import { promptScreen } from "./common.ts";
17 import type { PaletteEntry } from "./entries.ts";
18
19 export function profileScreen(f: Session, ctx: Ctx): Screen {
20 const save = (patch: { displayName: string } | { email: string }): string | undefined => {
21 const r = updateProfile(f.app, f.user, patch);
22 if (!r.ok) {
23 const words = PROFILE_REFUSALS[r.error] ?? PROFILE_FALLBACK;
outdated

Generic fallback is not a sentence a person can act on, and differs from the web page. Share one fallback in profile_words.ts.

24 ctx.toast(words, "danger");
25 return words;
26 }
27 const emailChanged = "email" in patch && r.value.email.toLowerCase() !== f.user?.email.toLowerCase();
28 f.user = r.value;
outdated

After an email change say a confirmation link was sent; the web page does.

29 ctx.toast(emailChanged ? emailSavedWords(r.value.email) : "saved", "success");
30 ctx.invalidate();
31 return undefined;
32 };
33
34 const screen: Screen = {
35 title: "Your profile",
36 keys: [binding(["n"], "set display name"), binding(["e"], "set email")],
37 render(area, s) {
38 const c = ctx.theme.c;
39 const u = f.user;
40 if (u === undefined) return;
41 const email = isStandInEmail(u.email) ? "none" : u.email;
42 const rows: [string, string][] = [
43 ["Username", u.username],
44 ["Display name", u.displayName],
45 ["Email", email],
46 ];
47 rows.forEach(([k, v], i) => {
48 const y = area.y + i;
49 s.text(area.x, y, k.padEnd(14), style(c.mutedFg, c.bg), area.x + area.w);
50 s.text(area.x + 14, y, truncate(v, Math.max(0, area.w - 14)), style(c.fg, c.bg), area.x + area.w);
51 });
52 },
53 key(e) {
54 if (e.ctrl || e.alt || f.user === undefined) return false;
55 if (e.key === "n") {
56 ctx.push(
57 promptScreen(ctx, screen, {
58 title: "Display name",
59 label: "display name",
60 value: f.user.displayName,
61 onSubmit: (v) => save({ displayName: v }),
62 }),
63 );
64 return true;
65 }
66 if (e.key === "e") {
67 ctx.push(
68 promptScreen(ctx, screen, {
69 title: "Email",
70 label: "email address",
71 value: isStandInEmail(f.user.email) ? "" : f.user.email,
72 onSubmit: (v) => save({ email: v }),
73 }),
74 );
75 return true;
76 }
77 return false;
78 },
79 };
80 return screen;
81 }
82
83 export const entry: PaletteEntry = {
84 id: "profile",
85 title: "Your profile",
86 hint: "display name and email",
87 offered: (session) => session.user !== undefined && session.user.kind !== "agent",
88 run: (session, ctx) => ctx.push(profileScreen(session, ctx)),
outdated

Needs a README line and a guest test; offered() is the only gate.

89 };
src/web/pages/account.ts +10 −10
@@ -33,6 +33,11 @@ import { linkEdgeLogin, linkedLogin, unlinkEdgeLogin } from "../../core/edge_lin
3333 import { LAST_WAY_IN, linkedParent, parentIssuerOf, unlinkParent } from "../../core/federation.ts";
3434 import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts";
3535 import { listKeys } from "../../core/keys.ts";
36 import {
37 PROFILE_FALLBACK,
38 PROFILE_REFUSAL_FIELD,
39 PROFILE_REFUSALS,
40 } from "../../actions/profile_words.ts";
3641 import { endAccountSession } from "../../actions/signins.ts";
3742 import { listSessions, type SessionInfo } from "../../core/sessions.ts";
3843 import { ADMIN_TOKEN_WORDS, listTokens, type TokenScope } from "../../core/tokens.ts";
@@ -100,7 +105,7 @@ interface ProfileForm {
100105 displayName: string;
101106 email: string;
102107 commitEmail: CommitEmail;
103 errors?: { displayName?: string; email?: string };
108 errors?: { displayName?: string; email?: string; form?: string };
104109 }
105110
106111 /** The profile form as the account stands now. */
@@ -279,6 +284,7 @@ function profileView(ctx: Ctx, values: ProfileForm): Html {
279284 const user = ctx.auth.user!;
280285 return html`${unverifiedNotice(ctx)}${roleView(ctx)}${card(
281286 html`<form method="post" action="/settings">
287 ${when(values.errors?.form, (m) => html`<p class="error-note">${m}</p>`)}
282288 <div class="field">
283289 <label for="f-username">Username</label>
284290 <input
@@ -404,16 +410,10 @@ export async function accountUpdate(ctx: Ctx): Promise<Response> {
404410 commitEmail,
405411 });
406412 if (!updated.ok) {
413 const words = PROFILE_REFUSALS[updated.error];
414 const at = PROFILE_REFUSAL_FIELD[updated.error];
407415 const errors: ProfileForm["errors"] =
408 updated.error === "email_taken"
409 ? { email: "Another account already uses that address." }
410 : updated.error === "invalid_email"
411 ? { email: "That doesn't look like an email address." }
412 : updated.error === "display_name_reserved"
413 ? { displayName: "That name belongs to the crew, the owner or the forge." }
414 : updated.error === "display_name_mixed_script"
415 ? { displayName: "Write it in one alphabet: letters from different scripts can pass for other names." }
416 : {};
416 words === undefined || at === undefined ? { form: PROFILE_FALLBACK } : { [at]: words };
417417 return accountShell(
418418 ctx,
419419 "profile",
test/account-profile-words.test.ts added +51
@@ -0,0 +1,51 @@
1 /**
2 * Settings › Account refuses a profile in the words the terminal shares
3 * (#8001): the web page and `PROFILE_REFUSALS` cannot drift apart.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { PROFILE_REFUSALS } from "../src/actions/profile_words.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { adminCookie } from "./helpers/admin.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let cookie: string;
21
22 beforeAll(async () => {
23 dataDir = await mkdtemp(join(tmpdir(), "forge-profile-words-"));
24 app = createApp({ dataDir, port: 0 });
25 servers = await serve(app);
26 cookie = await adminCookie(app, { username: "alice", email: "[email protected]" });
27 });
28 afterAll(async () => {
29 await servers.stop?.();
30 app.close();
31 await rm(dataDir, { recursive: true, force: true });
32 });
33
34 const post = (fields: Record<string, string>) =>
35 fetch(`${servers.url}/settings`, {
36 method: "POST",
37 redirect: "manual",
38 headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" },
39 body: new URLSearchParams(fields),
40 });
41
42 const escaped = (s: string) => s.replace(/'/g, "&#39;").replace(/&/g, "&amp;");
43
44 test("the account page refuses in the shared words, in the field they belong to", async () => {
45 const reserved = await post({ displayName: "forge", email: "[email protected]" });
46 expect(reserved.status).toBe(422);
47 expect(await reserved.text()).toContain(escaped(PROFILE_REFUSALS.display_name_reserved!).replace(/&amp;#39;/g, "&#39;"));
48 const bad = await post({ displayName: "Alice", email: "nope" });
49 expect(bad.status).toBe(422);
50 expect(await bad.text()).toContain(PROFILE_REFUSALS.invalid_email!.replace(/'/g, "&#39;"));
51 });
test/palette-roster.test.ts +2
@@ -112,6 +112,7 @@ test("a site-admin person is offered the whole roster", () => {
112112 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
113113 ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"],
114114 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
115 ["profile", "Your profile", "display name and email"],
115116 ["family", "Family", "the forge that sent this one, and the forges it sent"],
116117 ["chain", "Chain", "the forges this forge's code descends from, root first"],
117118 ["quit", "Quit", "disconnect"],
@@ -207,6 +208,7 @@ test("a member person is offered the signed-in roster without the admin entries"
207208 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
208209 ["origins", "Connected origins", "the hosts your mirrors come from"],
209210 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
211 ["profile", "Your profile", "display name and email"],
210212 ["family", "Family", "the forge that sent this one, and the forges it sent"],
211213 ["chain", "Chain", "the forges this forge's code descends from, root first"],
212214 ["quit", "Quit", "disconnect"],
test/tui-profile.test.ts added +163
@@ -0,0 +1,163 @@
1 /**
2 * Your profile in the terminal (#8001): `n` sets the display name, `e` the
3 * email, with the account page's words on refusal; an agent has no entry.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App as Forge } from "../src/app.ts";
10 import { createUser, getUserByName } from "../src/core/users.ts";
11 import { emailSavedWords, PROFILE_FALLBACK, PROFILE_REFUSALS } from "../src/actions/profile_words.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { type TuiIdentity } from "../src/tui/session.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 describe("your profile in the terminal", () => {
21 let dataDir: string;
22 let forge: Forge;
23
24 function boot(username: string | undefined) {
25 const terminal = headlessTerminal(140, 32);
26 const user = username === undefined ? undefined : getUserByName(forge.db, username)!;
27 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
28 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
29 app.start();
30 const key = (k: string) =>
31 app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
32 return {
33 text: () => app.screen.toText(),
34 press: async (...keys: string[]) => {
35 keys.forEach(key);
36 await settleApp(app);
37 },
38 ctrl: async (k: string) => {
39 app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false });
40 await settleApp(app);
41 },
42 type: async (t: string) => {
43 for (const ch of t) key(ch);
44 await settleApp(app);
45 },
46 stop: () => app.stop(),
47 };
48 }
49
50 beforeAll(async () => {
51 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-profile-"));
52 forge = createApp({ dataDir, port: 0 });
53 for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false], ["dave", false]] as const) {
54 const made = await createUser(forge.db, {
55 origin: "seed",
56 trusted: true,
57 username,
58 email: `${username}@forge.local`,
59 password: "a-good-passphrase",
60 adminIfFirst,
61 });
62 if (!made.ok) throw new Error(made.error);
63 }
64 forge.db.query("UPDATE user SET is_agent = 1 WHERE username = 'carol'").run();
65 });
66 afterAll(async () => {
67 forge.close();
68 await rm(dataDir, { recursive: true, force: true });
69 });
70
71 const open = async (h: ReturnType<typeof boot>) => {
72 await h.ctrl("k");
73 await h.type("Your profile");
74 await h.press("enter");
75 };
76 const clear = async (h: ReturnType<typeof boot>, n: number) => {
77 for (let i = 0; i < n; i++) await h.press("backspace");
78 };
79
80 test("n sets the display name and the screen shows it", async () => {
81 const h = boot("bob");
82 await open(h);
83 expect(h.text()).toContain("[email protected]");
84 await h.press("n");
85 await clear(h, 40);
86 await h.type("Bobby Tables");
87 await h.press("enter");
88 expect(getUserByName(forge.db, "bob")!.displayName).toBe("Bobby Tables");
89 expect(h.text()).toContain("saved");
90 expect(h.text()).toContain("Bobby Tables");
91 h.stop();
92 });
93
94 test("a reserved name is refused in the account page's words and changes nothing", async () => {
95 const before = getUserByName(forge.db, "bob")!.displayName;
96 const h = boot("bob");
97 await open(h);
98 await h.press("n");
99 await clear(h, 40);
100 await h.type("forge");
101 await h.press("enter");
102 expect(h.text()).toContain(PROFILE_REFUSALS.display_name_reserved!);
103 expect(getUserByName(forge.db, "bob")!.displayName).toBe(before);
104 h.stop();
105 });
106
107 test("e with a taken email toasts that refusal", async () => {
108 const h = boot("bob");
109 await open(h);
110 await h.press("e");
111 await clear(h, 40);
112 await h.type("[email protected]");
113 await h.press("enter");
114 expect(h.text()).toContain("Another account already uses that address.");
115 expect(getUserByName(forge.db, "bob")!.email).toBe("[email protected]");
116 h.stop();
117 });
118
119 test("an agent is not offered the entry", async () => {
120 const h = boot("carol");
121 await h.ctrl("k");
122 await h.type("Your profile");
123 expect(h.text()).not.toContain("display name and email");
124 h.stop();
125 });
126
127 test("a new email says a confirmation link was sent to it", async () => {
128 const h = boot("bob");
129 await open(h);
130 await h.press("e");
131 await clear(h, 40);
132 await h.type("[email protected]");
133 await h.press("enter");
134 expect(getUserByName(forge.db, "bob")!.email).toBe("[email protected]");
135 expect(h.text()).toContain(emailSavedWords("[email protected]"));
136 expect(emailSavedWords("[email protected]")).toContain("confirmation link was sent to [email protected]");
137 h.stop();
138 });
139
140 test("a refusal with no words of its own gets the shared fallback", async () => {
141 const h = boot("dave");
142 await open(h);
143 // The account vanishes under an open session: updateProfile answers no_such_user.
144 forge.db.query("DELETE FROM user WHERE username = 'dave'").run();
145 await h.press("n");
146 await clear(h, 40);
147 await h.type("Davey");
148 await h.press("enter");
149 expect(h.text()).toContain(PROFILE_FALLBACK);
150 h.stop();
151 });
152
153 test("a guest is not offered the entry and cannot reach the screen", async () => {
154 const h = boot(undefined);
155 await h.ctrl("k");
156 await h.type("Your profile");
157 expect(h.text()).not.toContain("display name and email");
158 await h.press("enter");
159 expect(h.text()).not.toContain("set display name");
160 expect(h.text()).not.toContain("Display name");
161 h.stop();
162 });
163 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs