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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2418-invites-part-2b-the-terminal-s-create-ac → main 2 commits fork 2 threads · 2 unresolved
5 files
changed +157
−21
README.md +2 −2
Diff not shown — a line 4,879 characters wide (limit 2,000). +2 −2
View filesrc/actions/users.ts +4
| @@ -66,6 +66,10 @@ export function signupClosed(app: App, invite?: string): boolean { | ||
| 66 | 66 | return newAccountsClosed(app.db, invite); |
| 67 | 67 | } |
| 68 | 68 | |
| 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 | ||
| 69 | 73 | /** What anyone is told at a door that still offers a password-only sign-up. */ |
| 70 | 74 | export const PASSKEY_REQUIRED_MESSAGE = |
| 71 | 75 | "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 @@ | ||
| 8 | 8 | * account while signup allows — not an admin; the operator makes the admin. |
| 9 | 9 | */ |
| 10 | 10 | |
| 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"; | |
| 12 | 12 | import { SignupSlot, waitText } from "../../actions/signup-window.ts"; |
| 13 | 13 | import { hasAnyUser } from "../../core/users.ts"; |
| 14 | import { getSignup } from "../../settings/signup.ts"; | |
| 14 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 16 | import { binding } from "../app/keys.ts"; |
| 16 | 17 | import { style } from "../term/style.ts"; |
| @@ -37,36 +38,51 @@ const CREATE: Field[] = [ | ||
| 37 | 38 | { label: "Password", secret: true, hint: "At least 10 characters." }, |
| 38 | 39 | ]; |
| 39 | 40 | |
| 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 | ||
| 40 | 45 | export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { |
| 41 | 46 | // "Is this forge empty?" is a existence question, not a count: `COUNT(*)` |
| 42 | 47 | // walks the whole user table on the one screen every session paints. |
| 43 | 48 | const first = !hasAnyUser(f.db); |
| 44 | 49 | // 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); | |
| 54 | let mode: "signin" | "create" = first && !closed() ? "create" : "signin"; | |
| 55 | let values = ["", "", "", ""]; | |
| 48 | 56 | let focus = 0; |
| 49 | 57 | let error: string | undefined; |
| 50 | 58 | let busy = false; |
| 51 | 59 | |
| 52 | const fields = (): Field[] => (mode === "create" ? CREATE : SIGN_IN); | |
| 60 | const fields = (): Field[] => (mode === "create" ? (invited() ? [...CREATE, INVITE] : CREATE) : SIGN_IN); | |
| 53 | 61 | |
| 54 | 62 | const submit = (): void => { |
| 55 | 63 | if (busy) return; |
| 56 | if (closed && mode === "create") { | |
| 64 | if (closed() && mode === "create") { | |
| 57 | 65 | error = SIGNUP_CLOSED_MESSAGE; |
| 58 | 66 | return; |
| 59 | 67 | } |
| 68 | const needsInvite = mode === "create" && invited(); | |
| 69 | if (needsInvite && (values[3] ?? "").trim() === "") { | |
| 70 | error = INVITE_BLANK_MESSAGE; | |
| 71 | focus = 3; | |
| 72 | return; | |
| 73 | } | |
| 60 | 74 | busy = true; |
| 61 | 75 | error = undefined; |
| 62 | 76 | runAsync(ctx, async () => { |
| 63 | 77 | try { |
| 64 | 78 | if (mode === "create") { |
| 65 | const [username, email, password] = values as [ | |
| 79 | const [username, email, password, token] = values as [ | |
| 80 | string, | |
| 66 | 81 | string, |
| 67 | 82 | string, |
| 68 | 83 | string, |
| 69 | 84 | ]; |
| 85 | const invite = needsInvite ? token.trim() : ""; | |
| 70 | 86 | const slot = SignupSlot.take(f.app, f.identity.remoteAddress); |
| 71 | 87 | if (typeof slot === "number") { |
| 72 | 88 | error = waitText(slot); |
| @@ -75,10 +91,11 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 75 | 91 | 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. |
||
| 76 | 92 | f.app, |
| 77 | 93 | { username, email, password }, |
| 78 | { slot, address: f.identity.remoteAddress }, | |
| 94 | { slot, address: f.identity.remoteAddress, ...(invite === "" ? {} : { invite }) }, | |
| 79 | 95 | ); |
| 80 | 96 | 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); | |
| 82 | 99 | return; |
| 83 | 100 | } |
| 84 | 101 | const adopted = f.adopt(created.value); |
| @@ -109,21 +126,21 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 109 | 126 | keys: [ |
| 110 | 127 | binding(["tab"], "next field"), |
| 111 | 128 | 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")]), | |
| 113 | 130 | binding(["esc"], "back"), |
| 114 | 131 | ], |
| 115 | 132 | render(area, s) { |
| 116 | 133 | const c = ctx.theme.c; |
| 117 | 134 | const [head, body] = split(area, "v", [len(2), fill(1)]); |
| 118 | 135 | segmented(s, head!, c, { |
| 119 | items: closed ? ["Sign in"] : ["Sign in", "Create account"], | |
| 136 | items: closed() ? ["Sign in"] : ["Sign in", "Create account"], | |
| 120 | 137 | active: mode === "create" ? 1 : 0, |
| 121 | 138 | }); |
| 122 | 139 | s.text( |
| 123 | 140 | head!.x, |
| 124 | 141 | head!.y + 1, |
| 125 | 142 | truncate( |
| 126 | closed | |
| 143 | closed() | |
| 127 | 144 | ? SIGNUP_CLOSED_MESSAGE |
| 128 | 145 | : first |
| 129 | 146 | ? "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 { | ||
| 162 | 179 | ctx.setCursor(cursor); |
| 163 | 180 | }, |
| 164 | 181 | key(e) { |
| 165 | if (e.ctrl && e.key === "t" && !closed) { | |
| 182 | if (e.ctrl && e.key === "t" && !closed()) { | |
| 166 | 183 | mode = mode === "create" ? "signin" : "create"; |
| 167 | values = ["", "", ""]; | |
| 184 | values = ["", "", "", ""]; | |
| 168 | 185 | focus = 0; |
| 169 | 186 | error = undefined; |
| 170 | 187 | return true; |
src/web/pages/site.ts +1 −5
| @@ -15,7 +15,7 @@ import { clientAddress } from "../../http/peer.ts"; | ||
| 15 | 15 | import { verifyEmail } from "../../actions/verify.ts"; |
| 16 | 16 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 17 | 17 | 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"; | |
| 19 | 19 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 20 | 20 | import { listPins } from "../../core/pins.ts"; |
| 21 | 21 | import { getReposByIds } from "../../core/repos.ts"; |
| @@ -419,10 +419,6 @@ export function inviteOf(ctx: Ctx): string | undefined { | ||
| 419 | 419 | return token === "" ? undefined : token; |
| 420 | 420 | } |
| 421 | 421 | |
| 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 | ||
| 426 | 422 | /** The whole of `/signup` while the forge makes no new accounts: a sentence, no form. */ |
| 427 | 423 | function signupClosedPage(ctx: Ctx): Response { |
| 428 | 424 | 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 | }); |
new above
offline
Computed once per screen; read the setting at use so it follows the live setting like the page does.