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

A signed-in person links and unlinks their open-platform.sh identity from account settings #2864

Merged opened this

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

Merged

scout/forge:scout/2596-a-signed-in-person-links-and-unlinks-the → main 2 commits fork 6 threads · 6 unresolved

  • Merged. scout/2596-a-signed-in-person-links-and-unlinks-the was merged into main as 33dd7fb.

Review

Sign in to review this change.
9 files changed +326 −10
README.md +5
@@ -368,6 +368,11 @@ matches; people then sign in with a password or a passkey. An admin with
368368 neither is refused, so no one locks themselves out. Accounts the edge made
369369 cannot sign in while it is off until they add a passkey (a password-less
370370 account cannot set a password: `/settings/password` asks for the current one).
371 Account settings show "open-platform.sh: linked as <login>" or "not linked": Link
372 records the login the request carries against the signed-in account, Unlink removes
373 it (same-origin POSTs). An edge sign-in finds the account by its link first, then by
374 name; one login links to one account, and a login that is another account's name
375 cannot be linked. The terminal's "What I can do" screen shows the same line and `u` unlinks (SSH carries no edge header, so linking is on the web page only).
371376 Mail out
372377 (the link that proves a newcomer's email) is SMTP submission over TLS,
373378 configured by the forge-level secrets `MAIL_SMTP_URL`
src/core/users.ts +5 −2
@@ -451,13 +451,16 @@ export function getUserByName(
451451
452452 /**
453453 * The person behind an identity the platform edge vouched for: found by
454 * name, never made. The edge vouches for a login, not for a right to an
454 * its link first, then by name, never made. The edge vouches for a login, not for a right to an
455455 * account here, so an unknown login is signed out and goes to /signup, where
456456 * the `signup` setting decides. Accounts the edge made before still sign in.
457457 * A name that is an organization or a crew member here is nobody's login.
458458 */
459459 export function ensureEdgeUser(db: Database, login: string): User | undefined {
460 const existing = getUserByName(db, login);
460 const link = db

Low: this repeats linkedUser's query from edge_links.ts, to avoid an import cycle. Fix later: move the one lookup into users.ts and have edge_links.ts import it, so the link-first rule lives in one place.

461 .query<{ user_id: string }, [string]>("SELECT user_id FROM edge_link WHERE login = ?")
462 .get(login);
463 const existing = (link === null ? undefined : getUserById(db, link.user_id)) ?? getUserByName(db, login);
461464 return existing && existing.kind === "user" && isActive(existing)
462465 ? existing
463466 : undefined;
src/http/server.ts +4
@@ -388,6 +388,8 @@ import {
388388 import {
389389 accountPage,
390390 notifyUpdate,
391 edgeLink,
392 edgeUnlink,
391393 accountUpdate,
392394 keyCreate,
393395 keyDelete,
@@ -755,6 +757,8 @@ export function buildRouter(): Router<Handler> {
755757 router.add("GET", "/settings", accountPage);
756758 router.add("POST", "/settings", accountUpdate);
757759 router.add("POST", "/settings/notify", notifyUpdate);
760 router.add("POST", "/settings/edge/link", edgeLink);
761 router.add("POST", "/settings/edge/unlink", edgeUnlink);
758762 router.add("POST", "/settings/email/verify", resendVerifyLink);
759763 router.add("GET", "/settings/password", passwordPage);
760764 router.add("POST", "/settings/password", passwordUpdate);
src/tui/screens/can.ts +20 −5
@@ -1,10 +1,14 @@
11 /**
22 * "What I can do" in the terminal, as the account page and the `whoami_can`
3 * tool have it: the signed-in account's role and its row of the roles table.
4 * Read only.
3 * tool have it: the signed-in account's role and its row of the roles table,
4 * and, where the platform edge signs people in, the open-platform.sh login
5 * linked to the account; `u` unlinks it (SSH carries no edge header, so
6 * linking is the web page's alone).
57 */
68
79 import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts";
10 import { linkedLogin, unlinkEdgeLogin } from "../../core/edge_links.ts";
11 import { activeEdgeToken } from "../../http/edge.ts";
812 import type { Ctx, Screen } from "../app/app.ts";
913 import { binding } from "../app/keys.ts";
1014 import type { Session } from "../session.ts";
@@ -12,18 +16,29 @@ import { style } from "../term/style.ts";
1216 import { truncate } from "../term/width.ts";
1317
1418 export function canScreen(f: Session, ctx: Ctx): Screen {
19 const edgeLogin = (): string | undefined => {
20 if (f.user === undefined || activeEdgeToken(f.app) === undefined) return undefined;
21 const linked = linkedLogin(f.app.db, f.user.id);
22 return linked === undefined ? "open-platform.sh: not linked" : `open-platform.sh: linked as ${linked}`;
23 };
1524 return {

Low: the help bar always says u unlink open-platform.sh, even when edge sign-in is off or nothing is linked. In those cases u does nothing but still swallows the key. Fix: build keys from edgeLogin(), the same way the line itself is gated, so the key is offered only when there is a link to remove.

1625 title: "What I can do",
17 keys: [binding(["esc"], "back")],
26 keys: [binding(["u"], "unlink open-platform.sh"), binding(["esc"], "back")],
1827 render(area, s) {
1928 const c = ctx.theme.c;
2029 if (f.user === undefined) return;
2130 const role = roleOf(f.app.db, f.user);
2231 const row = rowOf(role);
23 [`You are ${role} here.`, ...(Object.keys(ABILITIES) as Ability[]).map((a) => `${ABILITIES[a]}: ${row[a] ? "yes" : "no"}`)]
32 const login = edgeLogin();
33 [`You are ${role} here.`, ...(login === undefined ? [] : [login]), ...(Object.keys(ABILITIES) as Ability[]).map((a) => `${ABILITIES[a]}: ${row[a] ? "yes" : "no"}`)]
2434 .slice(0, Math.max(0, area.h))
2535 .forEach((l, i) => s.text(area.x, area.y + i, truncate(l, area.w), style(c.mutedFg, c.bg), area.x + area.w));
2636 },
27 key: () => false,
37 key(e) {
38 if (e.ctrl || e.alt || e.key !== "u" || f.user === undefined) return false;
39 if (activeEdgeToken(f.app) !== undefined && unlinkEdgeLogin(f.app.db, f.user.id))
40 f.app.bus.append({ kind: "account.edge_unlinked", actorId: f.user.id });
41 return true;
42 },
2843 };
2944 }
src/web/pages/account.ts +59 −1
@@ -28,6 +28,8 @@ import {
2828 } from "../../crew/notify.ts";
2929 import { forgeHost, noreplyAddress } from "../../core/commit_identity.ts";
3030 import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts";
31 import { linkEdgeLogin, linkedLogin, unlinkEdgeLogin } from "../../core/edge_links.ts";
32 import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts";
3133 import { listKeys } from "../../core/keys.ts";
3234 import { endAccountSession } from "../../actions/signins.ts";
3335 import { listSessions, type SessionInfo } from "../../core/sessions.ts";
@@ -125,6 +127,62 @@ function notifyView(ctx: Ctx, error?: string): Html {
125127 );
126128 }
127129
130 /** open-platform.sh: linked as <login>, or not, with Link and Unlink. */
131 function edgeLinkView(ctx: Ctx): Html {
132 const token = activeEdgeToken(ctx.app);
133 if (token === undefined) return html``;
134 const linked = linkedLogin(ctx.app.db, ctx.auth.user!.id);
135 const carried = edgeIdentity(ctx.request, token);
136 return card(
137 linked !== undefined
138 ? html`<p>open-platform.sh: linked as <strong>${linked}</strong></p>
139 <form method="post" action="/settings/edge/unlink">
140 ${btn("Unlink", { type: "submit" })}
141 </form>`
142 : html`<p>open-platform.sh: not linked</p>
143 ${carried === undefined
144 ? html`<p class="hint">Sign in through open-platform.sh, then come back here to link that login.</p>`
145 : html`<form method="post" action="/settings/edge/link">
146 ${btn(`Link ${carried}`, { type: "submit" })}
147 </form>`}`,
148 { head: html`<h3>open-platform.sh</h3>` },
149 );
150 }
151
152 // POST /settings/edge/link
153 export async function edgeLink(ctx: Ctx): Promise<Response> {
154 if (!ctx.auth.user) return signInFirst(ctx);
155 const form = await readForm(ctx, "admin");
156 if (form instanceof Response) return form;
157 const login = edgeIdentity(ctx.request, activeEdgeToken(ctx.app));
158 if (login === undefined)
159 return flashTo(ctx, "/settings", {
160 message: "Sign in through open-platform.sh first, then link.",
161 });
162 const linked = linkEdgeLogin(ctx.app.db, ctx.auth.user, login);
163 if (!linked.ok)
164 return flashTo(ctx, "/settings", {
165 message:
166 linked.error === "already_linked"
167 ? "This account is linked to another open-platform.sh login. Unlink it first."
168 : linked.error === "login_taken"
169 ? "That open-platform.sh login belongs to another account here."
170 : "That open-platform.sh login cannot be linked.",
171 });
172 ctx.app.bus.append({ kind: "account.edge_linked", actorId: ctx.auth.user.id });
173 return flashTo(ctx, "/settings", { message: `Linked as ${login}.` });
174 }
175
176 // POST /settings/edge/unlink
177 export async function edgeUnlink(ctx: Ctx): Promise<Response> {

The page's Unlink, and the linked/not-linked status, have no terminal counterpart. Add the status line and an unlink key to the terminal's account screen, test it, and add it to the README.

178 if (!ctx.auth.user) return signInFirst(ctx);
179 const form = await readForm(ctx, "admin");
180 if (form instanceof Response) return form;
181 if (unlinkEdgeLogin(ctx.app.db, ctx.auth.user.id))
182 ctx.app.bus.append({ kind: "account.edge_unlinked", actorId: ctx.auth.user.id });
183 return flashTo(ctx, "/settings", { message: "Unlinked." });
184 }
185
128186 /** Shown above the profile until the email is proven. */
129187 function unverifiedNotice(ctx: Ctx): Html {
130188 const user = ctx.auth.user!;
@@ -245,7 +303,7 @@ export function accountPage(ctx: Ctx): Response {
245303 ctx,
246304 "profile",
247305 "Settings",
248 html`${pair}${profileView(ctx, currentProfile(ctx.auth.user))}${notifyView(ctx)}`,
306 html`${pair}${profileView(ctx, currentProfile(ctx.auth.user))}${edgeLinkView(ctx)}${notifyView(ctx)}`,
249307 200,
250308 // The everyday form makes its account with a passkey (#1890).
251309 pair ? passkeyJs : undefined,
test/tui-can.test.ts +19 −2
@@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
66 import { mkdtemp, rm } from "node:fs/promises";
77 import { tmpdir } from "node:os";
88 import { join } from "node:path";
9 import { linkEdgeLogin, linkedLogin } from "../src/core/edge_links.ts";
910 import { createApp, type App as Forge } from "../src/app.ts";
1011 import { setMember } from "../src/core/roles.ts";
1112 import { createUser, getUserByName } from "../src/core/users.ts";
@@ -21,7 +22,7 @@ describe("what I can do in the terminal", () => {
2122 let dataDir: string;
2223 let forge: Forge;
2324
24 async function shown(username: string): Promise<string> {
25 async function shown(username: string, presses: string[] = []): Promise<string> {
2526 const terminal = headlessTerminal(140, 32);
2627 const user = getUserByName(forge.db, username)!;
2728 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
@@ -32,6 +33,8 @@ describe("what I can do in the terminal", () => {
3233 for (const ch of "What I can do") key(ch);
3334 key("enter");
3435 await settleApp(app);
36 for (const k of presses) key(k);
37 await settleApp(app);
3538 const text = app.screen.toText();
3639 app.stop();
3740 return text;
@@ -39,7 +42,7 @@ describe("what I can do in the terminal", () => {
3942
4043 beforeAll(async () => {
4144 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-can-"));
42 forge = createApp({ dataDir, port: 0 });
45 forge = createApp({ dataDir, port: 0, edgeToken: "edge-secret-0123456789" });
4346 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
4447 const made = await createUser(forge.db, {
4548 origin: "seed",
@@ -70,4 +73,18 @@ describe("what I can do in the terminal", () => {
7073 expect(admin).toContain("You are admin here.");
7174 expect(admin).toContain("Administer the forge: yes");
7275 });
76
77 test("the screen shows the open-platform.sh link, and u unlinks it and says so on the bus", async () => {
78 const bob = getUserByName(forge.db, "bob")!;
79 expect(await shown("bob")).toContain("open-platform.sh: not linked");
80 expect(linkEdgeLogin(forge.db, bob, "octo").ok).toBe(true);
81 expect(await shown("bob")).toContain("open-platform.sh: linked as octo");
82 const seen: string[] = [];
83 const stop = forge.bus.subscribe((e) => seen.push(e.kind));
84 const after = await shown("bob", ["u"]);
85 stop();
86 expect(after).toContain("open-platform.sh: not linked");
87 expect(linkedLogin(forge.db, bob.id)).toBeUndefined();
88 expect(seen).toContain("account.edge_unlinked");
89 });
7390 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs