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

Invites, part 2b: the terminal's create-account screen asks for an invite when signup = invite #2572

Merged opened this

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

Merged

scout/forge:scout/2418-invites-part-2b-the-terminal-s-create-ac → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2418-invites-part-2b-the-terminal-s-create-ac was merged into main as 35683ed.

Review

Sign in to review this change.
5 files changed +157 −21
README.md +2 −2

Diff not shown — a line 4,879 characters wide (limit 2,000). +2 −2

View file
src/actions/users.ts +4
@@ -66,6 +66,10 @@ export function signupClosed(app: App, invite?: string): boolean {
6666 return newAccountsClosed(app.db, invite);
6767 }
6868
69 /** What a person with an invite link that no longer opens the door is told. */
70 export const INVITE_SPENT_MESSAGE =
71 "This invite link has been used, revoked or has expired. Ask whoever sent it for a new one.";
72
6973 /** What anyone is told at a door that still offers a password-only sign-up. */
7074 export const PASSKEY_REQUIRED_MESSAGE =
7175 "New accounts sign up with a passkey and an email address. Open the sign-up page in a browser and choose \u201cCreate account with a passkey\u201d.";
src/tui/screens/signin.ts +31 −14
@@ -8,9 +8,10 @@
88 * account while signup allows — not an admin; the operator makes the admin.
99 */
1010
11 import { SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
11 import { INVITE_SPENT_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
1212 import { SignupSlot, waitText } from "../../actions/signup-window.ts";
1313 import { hasAnyUser } from "../../core/users.ts";
14 import { getSignup } from "../../settings/signup.ts";
1415 import type { Ctx, Screen } from "../app/app.ts";
1516 import { binding } from "../app/keys.ts";
1617 import { style } from "../term/style.ts";
@@ -37,36 +38,51 @@ const CREATE: Field[] = [
3738 { label: "Password", secret: true, hint: "At least 10 characters." },
3839 ];
3940
41 /** While signup is `invite` the create form also asks for the link's token. */
42 const INVITE_BLANK_MESSAGE = "Paste the token from your invite link.";
43 const INVITE: Field = { label: "Invite", secret: true, hint: "The token from your invite link." };
44
4045 export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
4146 // "Is this forge empty?" is a existence question, not a count: `COUNT(*)`
4247 // walks the whole user table on the one screen every session paints.
4348 const first = !hasAnyUser(f.db);
4449 // Closed signup offers no create mode at all, as the page offers no form.
45 const closed = signupClosed(f.app);
46 let mode: "signin" | "create" = first && !closed ? "create" : "signin";
47 let values = ["", "", ""];
50 // With signup = invite the form is offered, and asks for the invite.
51 // Both are read where they are used, so the screen follows the live setting.
52 const invited = (): boolean => getSignup(f.db) === "invite";
53 const closed = (): boolean => !invited() && signupClosed(f.app);
outdated

Computed once per screen; read the setting at use so it follows the live setting like the page does.

54 let mode: "signin" | "create" = first && !closed() ? "create" : "signin";
55 let values = ["", "", "", ""];
4856 let focus = 0;
4957 let error: string | undefined;
5058 let busy = false;
5159
52 const fields = (): Field[] => (mode === "create" ? CREATE : SIGN_IN);
60 const fields = (): Field[] => (mode === "create" ? (invited() ? [...CREATE, INVITE] : CREATE) : SIGN_IN);
5361
5462 const submit = (): void => {
5563 if (busy) return;
56 if (closed && mode === "create") {
64 if (closed() && mode === "create") {
5765 error = SIGNUP_CLOSED_MESSAGE;
5866 return;
5967 }
68 const needsInvite = mode === "create" && invited();
69 if (needsInvite && (values[3] ?? "").trim() === "") {
70 error = INVITE_BLANK_MESSAGE;
71 focus = 3;
72 return;
73 }
6074 busy = true;
6175 error = undefined;
6276 runAsync(ctx, async () => {
6377 try {
6478 if (mode === "create") {
65 const [username, email, password] = values as [
79 const [username, email, password, token] = values as [
80 string,
6681 string,
6782 string,
6883 string,
6984 ];
85 const invite = needsInvite ? token.trim() : "";
7086 const slot = SignupSlot.take(f.app, f.identity.remoteAddress);
7187 if (typeof slot === "number") {
7288 error = waitText(slot);
@@ -75,10 +91,11 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
7591 const created = await signUp(
outdated

A blank token reaches here too and is told it was 'used, revoked or expired'. Refuse a blank token earlier with its own plain sentence.

7692 f.app,
7793 { username, email, password },
78 { slot, address: f.identity.remoteAddress },
94 { slot, address: f.identity.remoteAddress, ...(invite === "" ? {} : { invite }) },
7995 );
8096 if (!created.ok) {
81 error = errorText(created.error);
97 // With signup = invite a refusal means the token did not open the door.
98 error = needsInvite && created.error === "signup_closed" ? INVITE_SPENT_MESSAGE : errorText(created.error);
8299 return;
83100 }
84101 const adopted = f.adopt(created.value);
@@ -109,21 +126,21 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
109126 keys: [
110127 binding(["tab"], "next field"),
111128 binding(["enter"], mode === "create" ? "create" : "sign in"),
112 ...(closed ? [] : [binding(["ctrl+t"], first ? "sign in instead" : "create account")]),
129 ...(closed() ? [] : [binding(["ctrl+t"], first ? "sign in instead" : "create account")]),
113130 binding(["esc"], "back"),
114131 ],
115132 render(area, s) {
116133 const c = ctx.theme.c;
117134 const [head, body] = split(area, "v", [len(2), fill(1)]);
118135 segmented(s, head!, c, {
119 items: closed ? ["Sign in"] : ["Sign in", "Create account"],
136 items: closed() ? ["Sign in"] : ["Sign in", "Create account"],
120137 active: mode === "create" ? 1 : 0,
121138 });
122139 s.text(
123140 head!.x,
124141 head!.y + 1,
125142 truncate(
126 closed
143 closed()
127144 ? SIGNUP_CLOSED_MESSAGE
128145 : first
129146 ? "This forge has no accounts yet. The operator makes the admin."
@@ -162,9 +179,9 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
162179 ctx.setCursor(cursor);
163180 },
164181 key(e) {
165 if (e.ctrl && e.key === "t" && !closed) {
182 if (e.ctrl && e.key === "t" && !closed()) {
166183 mode = mode === "create" ? "signin" : "create";
167 values = ["", "", ""];
184 values = ["", "", "", ""];
168185 focus = 0;
169186 error = undefined;
170187 return true;
src/web/pages/site.ts +1 −5
@@ -15,7 +15,7 @@ import { clientAddress } from "../../http/peer.ts";
1515 import { verifyEmail } from "../../actions/verify.ts";
1616 import { STRANGER_MAX } from "../../http/limits.ts";
1717 import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts";
18 import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
18 import { HUMAN_CHECK_MESSAGE, INVITE_SPENT_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
1919 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
2020 import { listPins } from "../../core/pins.ts";
2121 import { getReposByIds } from "../../core/repos.ts";
@@ -419,10 +419,6 @@ export function inviteOf(ctx: Ctx): string | undefined {
419419 return token === "" ? undefined : token;
420420 }
421421
422 /** What a person with a link that no longer opens the door is told. */
423 const INVITE_SPENT_MESSAGE =
424 "This invite link has been used, revoked or has expired. Ask whoever sent it for a new one.";
425
426422 /** The whole of `/signup` while the forge makes no new accounts: a sentence, no form. */
427423 function signupClosedPage(ctx: Ctx): Response {
428424 const said = inviteOf(ctx) === undefined ? SIGNUP_CLOSED_MESSAGE : INVITE_SPENT_MESSAGE;
test/tui-signup-invite.test.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * With signup = invite the terminal's create-account screen asks for the
3 * invite token and redeems it as the web form does: a good token makes the
4 * account and is spent; a bad one gets one plain refusal and makes none.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { signUp } from "../src/actions/users.ts";
11 import { createApp, type App as Forge } from "../src/app.ts";
12 import { listInvites, mintInvite } from "../src/core/invites.ts";
13 import { getUserByName } from "../src/core/users.ts";
14 import { setSignup } from "../src/settings/signup.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import type { TuiIdentity } from "../src/tui/session.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { fixtureSlot } from "./helpers/slot.ts";
20 import { settleApp } from "./helpers/tui.ts";
21
22 quietLogs();
23
24 const KEY =
25 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo terminal-test";
26
27 describe("terminal sign-up with an invite", () => {
28 let dir: string;
29 let forge: Forge;
30 let adminId: string;
31
32 beforeAll(async () => {
33 dir = await mkdtemp(join(tmpdir(), "forge-tui-invite-signup-"));
34 forge = createApp({ dataDir: dir, port: 0, disk: () => ({ freeBytes: 95, totalBytes: 100 }) });
35 const admin = await signUp(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }, { slot: fixtureSlot(forge) });
36 if (!admin.ok) throw new Error(admin.error);
37 adminId = admin.value.id;
38 setSignup(forge.db, "invite", adminId);
39 });
40 afterAll(async () => {
41 forge.close();
42 await rm(dir, { recursive: true, force: true });
43 });
44
45 const attempt = async (name: string, token: string): Promise<string> => {
46 const identity: TuiIdentity = { sshUsername: "git", remoteAddress: "198.51.100.7", fingerprint: "SHA256:x", publicKey: KEY };
47 const { app } = createForgeApp(forge, identity, { terminal: headlessTerminal(120, 32), exitHooks: false });
48 app.start();
49 const key = (k: string, ctrl = false): void =>
50 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
51 const type = (t: string): void => [...t].forEach((c) => key(c));
52 try {
53 await settleApp(app);
54 key("s");
55 await settleApp(app);
56 key("t", true);
57 await settleApp(app);
58 expect(app.screen.toText()).toContain("Invite");
59 type(name);
60 key("tab");
61 type(`${name}@forge.test`);
62 key("tab");
63 type("a-long-passphrase");
64 key("tab");
65 type(token);
66 key("enter");
67 await settleApp(app);
68 return app.screen.toText();
69 } finally {
70 app.stop();
71 }
72 };
73
74 test("a bad token gets one plain refusal and makes no account", async () => {
75 const text = await attempt("mallory", "invite_nope");
76 expect(text).toContain("This invite link has been used, revoked or has expired.");
77 expect(text).not.toContain("Signup is closed");
78 expect(getUserByName(forge.db, "mallory")).toBeFalsy();
79 });
80
81 test("a blank token is asked for, not called spent", async () => {
82 const text = await attempt("blanca", "");
83 expect(text).toContain("Paste the token from your invite link.");
84 expect(text).not.toContain("used, revoked");
85 expect(getUserByName(forge.db, "blanca")).toBeFalsy();
86 });
87
88 test("the screen follows the live setting", async () => {
89 const identity: TuiIdentity = { sshUsername: "git", remoteAddress: "198.51.100.8", fingerprint: "SHA256:y", publicKey: KEY };
90 const { app } = createForgeApp(forge, identity, { terminal: headlessTerminal(120, 32), exitHooks: false });
91 app.start();
92 const key = (k: string, ctrl = false): void =>
93 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
94 try {
95 await settleApp(app);
96 key("s");
97 await settleApp(app);
98 key("t", true);
99 await settleApp(app);
100 expect(app.screen.toText()).toContain("Invite");
101 setSignup(forge.db, "open", adminId);
102 key("x");
103 await settleApp(app);
104 expect(app.screen.toText()).not.toContain("Invite");
105 } finally {
106 app.stop();
107 setSignup(forge.db, "invite", adminId);
108 }
109 });
110
111 test("a good token makes the account and is spent; reusing it is refused", async () => {
112 const { token } = mintInvite(forge.db, adminId);
113 await attempt("dana", token);
114 expect(getUserByName(forge.db, "dana")).toBeTruthy();
115 expect(listInvites(forge.db).every((i) => i.state === "used")).toBe(true);
116 await attempt("eve", token);
117 expect(getUserByName(forge.db, "eve")).toBeFalsy();
118 });
119 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs