Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; | |
| 3 | ||
| 4 | import { | |
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 5 | filterInvitations, |
| 6 | filterMembers, | |
| 7 | invitationCounts, | |
| 8 | invitationFilter, | |
| 9 | invitationFilterOf, | |
| 10 | invitationTone, | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 11 | inviteKind, |
| Anyone on g1t can invite a friend again: Invite a friend, in the account menu with how many are left beside it, opens Settings → Invites to g1t with a dialog for their email address and a note. The friend gets an email from them whose link opens sign-up with the address filled in and, because it came from that email, confirmed; their account starts with a workspace of its own, and the page lists every invite sent with where it stands (Pending, confirming their email, Joined as @them), with Copy link, Send again and Revoke on each. It is word of mouth, never a gate: sign-ups stay open to everyone, nothing asks for an invite, and running out says friends can still sign up without one. | 12 | G1T_INVITES, |
| 13 | bringIntoChoices, | |
| 14 | inviteActions, | |
| 15 | inviteDraft, | |
| 16 | leftBadge, | |
| 17 | remainingLine, | |
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 18 | memberFilters, |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 19 | invitePageCopy, |
| 20 | workspaceInviteCopy, | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 21 | cleanCode, |
| Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm | 22 | cleanProof, |
| 23 | invitePath, | |
| 24 | inviteSignUpCopy, | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 25 | inviteFor, |
| 26 | inviteLink, | |
| 27 | inviteState, | |
| 28 | landingFor, | |
| 29 | looksAutomated, | |
| 30 | signUpCopy, | |
| 31 | suggestUsername, | |
| 32 | welcomeCookie, | |
| 33 | clearWelcome, | |
| 34 | welcomes, | |
| 35 | } from "./invites.ts"; | |
| 36 | ||
| 37 | const CODE = "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk"; | |
| 38 | ||
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 39 | test("the Invitations page sorts every state under one filter, and counts them", () => { |
| 40 | const statuses = ["pending", "awaiting_confirmation", "awaiting_answer", "redeemed", "declined", "expired", "revoked"] as const; | |
| 41 | const invites = statuses.map((status) => ({ status })); | |
| 42 | assert.equal(invitationFilterOf("pending"), "pending"); | |
| 43 | assert.equal(invitationFilterOf("awaiting_confirmation"), "pending"); | |
| 44 | assert.equal(invitationFilterOf("awaiting_answer"), "pending"); | |
| 45 | assert.equal(invitationFilterOf("redeemed"), "accepted"); | |
| 46 | assert.equal(invitationFilterOf("declined"), "declined"); | |
| 47 | assert.equal(invitationFilterOf("expired"), "expired"); | |
| 48 | assert.equal(invitationFilterOf("revoked"), "revoked"); | |
| 49 | assert.deepEqual(invitationCounts(invites), { all: 7, pending: 3, accepted: 1, declined: 1, expired: 1, revoked: 1 }); | |
| 50 | assert.equal(filterInvitations(invites, "all").length, 7); | |
| 51 | assert.deepEqual(filterInvitations(invites, "pending").map((invite) => invite.status), ["pending", "awaiting_confirmation", "awaiting_answer"]); | |
| 52 | assert.deepEqual(filterInvitations(invites, "accepted").map((invite) => invite.status), ["redeemed"]); | |
| 53 | // The query names a filter, or means All. | |
| 54 | assert.equal(invitationFilter("pending"), "pending"); | |
| 55 | assert.equal(invitationFilter("failed"), "all"); | |
| 56 | assert.equal(invitationFilter(null), "all"); | |
| 57 | // Waiting is amber, joined green, a no red, and over is quiet. | |
| 58 | assert.equal(invitationTone("awaiting_answer"), "warn"); | |
| 59 | assert.equal(invitationTone("redeemed"), "success"); | |
| 60 | assert.equal(invitationTone("revoked"), "danger"); | |
| 61 | assert.equal(invitationTone("expired"), "neutral"); | |
| 62 | }); | |
| 63 | ||
| 64 | test("the Members page filters by role, two-factor, team and words", () => { | |
| 65 | const members = [ | |
| 66 | { username: "ada", name: "Ada Lovelace", role: "owner" as const, two_factor: true }, | |
| 67 | { username: "grace", name: "Grace Hopper", role: "member" as const, two_factor: false }, | |
| 68 | { username: "linus", name: null, role: "member" as const, two_factor: true }, | |
| 69 | ]; | |
| 70 | const teams = { ada: [{ slug: "core" }], grace: [{ slug: "web" }, { slug: "core" }] }; | |
| 71 | assert.deepEqual(memberFilters(""), { role: "all", twoFactor: "all", team: null, query: "" }); | |
| 72 | assert.deepEqual(memberFilters("?role=owner&2fa=off&team=Core&q=%20ada%20"), { role: "owner", twoFactor: "off", team: "core", query: "ada" }); | |
| 73 | assert.deepEqual(memberFilters("?role=admin&2fa=maybe"), { role: "all", twoFactor: "all", team: null, query: "" }); | |
| 74 | const names = (filters: string) => filterMembers(members, memberFilters(filters), teams).map((member) => member.username); | |
| 75 | assert.deepEqual(names(""), ["ada", "grace", "linus"]); | |
| 76 | assert.deepEqual(names("?role=member"), ["grace", "linus"]); | |
| 77 | assert.deepEqual(names("?2fa=off"), ["grace"]); | |
| 78 | assert.deepEqual(names("?team=core"), ["ada", "grace"]); | |
| 79 | assert.deepEqual(names("?q=hopper"), ["grace"]); | |
| 80 | assert.deepEqual(names("?q=@LIN"), ["linus"]); | |
| 81 | assert.deepEqual(names("?role=owner&team=web"), []); | |
| 82 | }); | |
| 83 | ||
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 84 | test("everyone is offered a plain sign-up", () => { |
| Sign-up buttons say Sign up everywhere; only the sign-up page says registration takes an invite | 85 | assert.deepEqual(signUpCopy(), { primary: "Sign up", secondary: null }); |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 86 | }); |
| 87 | ||
| 88 | test("an invite link is on g1t.sh unless told otherwise", () => { | |
| 89 | assert.equal(inviteLink(CODE), `https://g1t.sh/invite/${CODE}`); | |
| 90 | assert.equal(inviteLink(CODE, "http://localhost:8787/"), `http://localhost:8787/invite/${CODE}`); | |
| 91 | }); | |
| 92 | ||
| Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm | 93 | const PROOF = "4f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c8"; |
| 94 | ||
| 95 | test("an invite email's proof is kept only when it looks like one, and goes along to the invite's page", () => { | |
| 96 | assert.equal(cleanProof(PROOF), PROOF); | |
| 97 | assert.equal(cleanProof(` ${PROOF.toUpperCase()} `), PROOF); | |
| 98 | assert.equal(cleanProof("not-a-proof"), null); | |
| 99 | assert.equal(cleanProof("abc"), null); | |
| 100 | assert.equal(cleanProof("a".repeat(500)), null); | |
| 101 | assert.equal(cleanProof(null), null); | |
| 102 | assert.equal(invitePath(CODE, PROOF), `/invite/${CODE}?proof=${PROOF}`); | |
| 103 | assert.equal(invitePath(CODE, null), `/invite/${CODE}`); | |
| 104 | assert.equal(invitePath(CODE), `/invite/${CODE}`); | |
| 105 | }); | |
| 106 | ||
| 107 | test("signing up from the invite email says the address is confirmed already; otherwise the code step applies", () => { | |
| 108 | const base = { address: "ada@example.com", emailProven: false, workspace: { name: "Flagon, Inc." }, repository: null }; | |
| 109 | const proven = inviteSignUpCopy({ ...base, emailProven: true }); | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 110 | assert.equal(proven.intro, "You can join Flagon, Inc. as soon as you create it: accept the invitation then."); |
| Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm | 111 | assert.match(proven.confirmed ?? "", /^ada@example\.com is confirmed: you came here from the invite we emailed to it/); |
| 112 | assert.match(proven.hint, /confirmed already/); | |
| 113 | assert.doesNotMatch(proven.hint, /code/); | |
| 114 | ||
| 115 | // No proof (a code typed in, or a link passed on): nothing new is said. | |
| 116 | const plain = inviteSignUpCopy(base); | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 117 | assert.equal(plain.intro, "You can join Flagon, Inc. as soon as you confirm your email: accept the invitation then."); |
| Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm | 118 | assert.equal(plain.confirmed, null); |
| 119 | assert.equal(plain.hint, "Your invite was sent here. We email it a code to confirm it before you start."); | |
| 120 | ||
| 121 | // An invite for anyone with the code has no address to prove. | |
| 122 | const open = inviteSignUpCopy({ ...base, address: null, emailProven: true, workspace: null }); | |
| 123 | assert.equal(open.confirmed, null); | |
| 124 | assert.equal(open.intro, "It takes a minute."); | |
| 125 | assert.equal(open.hint, "We email it a code to confirm it before you start."); | |
| 126 | ||
| 127 | const repo = inviteSignUpCopy({ ...base, workspace: null, repository: { name: "flagon-io/g1t" }, emailProven: true }); | |
| 128 | assert.equal(repo.intro, "You get flagon-io/g1t as soon as you create it."); | |
| 129 | }); | |
| 130 | ||
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 131 | test("a pasted link or code is tidied to the code", () => { |
| 132 | assert.equal(cleanCode(CODE), CODE); | |
| 133 | assert.equal(cleanCode(` ${CODE} `), CODE); | |
| 134 | assert.equal(cleanCode(`https://g1t.sh/invite/${CODE}`), CODE); | |
| 135 | assert.equal(cleanCode(`https://g1t.sh/register?invite=${CODE}&next=%2F`), CODE); | |
| 136 | assert.equal(cleanCode("g1t-k7m2 q9xd"), "g1t-k7m2q9xd"); | |
| 137 | assert.equal(cleanCode(null), ""); | |
| 138 | assert.equal(cleanCode("x".repeat(500)).length, 80); | |
| 139 | }); | |
| 140 | ||
| 141 | test("each invite says where it stands and whom it is for", () => { | |
| 142 | const base = { redeemedBy: null, email: null, workspace: null }; | |
| 143 | assert.deepEqual(inviteState({ ...base, status: "pending" }), { label: "Pending", tone: "pending" }); | |
| 144 | assert.deepEqual(inviteState({ ...base, status: "redeemed", redeemedBy: "ada" }), { label: "Joined as @ada", tone: "done" }); | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 145 | assert.deepEqual(inviteState({ ...base, status: "awaiting_confirmation", redeemedBy: "ada" }), { |
| 146 | label: "@ada is confirming their email", | |
| 147 | tone: "pending", | |
| 148 | }); | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 149 | assert.deepEqual(inviteState({ ...base, status: "expired" }), { label: "Expired", tone: "dead" }); |
| 150 | assert.deepEqual(inviteState({ ...base, status: "revoked" }), { label: "Revoked", tone: "dead" }); | |
| 151 | assert.equal(inviteFor({ ...base, status: "pending" }), "Anyone with the link"); | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 152 | assert.equal(inviteFor({ ...base, status: "pending", email: "ada@example.com", workspace: "acme" }), "ada@example.com"); |
| 153 | assert.equal(inviteFor({ ...base, status: "pending", invitee: "daweazl", workspace: "flagon-io" }), "@daweazl"); | |
| 154 | // Which kind each is, beside whom it is for. | |
| 155 | assert.deepEqual(inviteKind({ workspace: null }), { kind: "g1t", label: "Invite to g1t" }); | |
| 156 | assert.deepEqual(inviteKind({ workspace: "flagon-io" }), { kind: "workspace", label: "Invite to join flagon-io" }); | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 157 | assert.deepEqual(inviteState({ ...base, status: "awaiting_answer", redeemedBy: "daweazl" }), { label: "Waiting for @daweazl to accept", tone: "pending" }); |
| 158 | assert.deepEqual(inviteState({ ...base, status: "declined", invitee: "daweazl" }), { label: "@daweazl declined", tone: "dead" }); | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 159 | }); |
| 160 | ||
| 161 | test("bots that fill the hidden field or answer instantly are turned away", () => { | |
| 162 | const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null }); | |
| 163 | const now = 1_000_000; | |
| 164 | assert.equal(looksAutomated(form({ website: "http://spam.example" }), now), true); | |
| 165 | assert.equal(looksAutomated(form({ started: String(now - 200) }), now), true); | |
| 166 | assert.equal(looksAutomated(form({ started: String(now - 10_000) }), now), false); | |
| 167 | assert.equal(looksAutomated(form({}), now), false); | |
| 168 | assert.equal(looksAutomated(form({ website: " " }), now), false); | |
| 169 | }); | |
| 170 | ||
| 171 | test("a username is suggested from the invited address", () => { | |
| 172 | assert.equal(suggestUsername("ada.lovelace@example.com"), "ada-lovelace"); | |
| 173 | assert.equal(suggestUsername("Margaret_Hamilton+g1t@example.com"), "margaret-hamilton"); | |
| 174 | assert.equal(suggestUsername("--x--@example.com"), "x"); | |
| 175 | assert.equal(suggestUsername(`${"a".repeat(38)}.b@example.com`), "a".repeat(38)); | |
| 176 | assert.equal(suggestUsername("...@example.com"), ""); | |
| 177 | assert.equal(suggestUsername(null), ""); | |
| 178 | }); | |
| 179 | ||
| 180 | test("an invite lands in its workspace, else its repository", () => { | |
| 181 | assert.equal(landingFor({ workspace: { slug: "Flagon-IO" }, repository: null }), "flagon-io"); | |
| 182 | assert.equal(landingFor({ workspace: null, repository: { name: "flagon-io/g1t" } }), "flagon-io/g1t"); | |
| 183 | assert.equal(landingFor({ workspace: null, repository: null }), null); | |
| 184 | }); | |
| 185 | ||
| 186 | test("the welcome is for one place, and ends", () => { | |
| 187 | const set = welcomeCookie("flagon-io/g1t", true); | |
| 188 | assert.match(set, /^g1t_welcome=flagon-io%2Fg1t; Path=\/; Max-Age=300; HttpOnly; SameSite=Lax; Secure$/); | |
| 189 | const header = `a=1; ${set.split(";")[0]}; b=2`; | |
| 190 | assert.equal(welcomes(header, "flagon-io/g1t"), true); | |
| 191 | assert.equal(welcomes(header, "flagon-io"), false); | |
| 192 | assert.equal(welcomes("g1t_welcome=flagon-io", "Flagon-IO"), true); | |
| 193 | assert.equal(welcomes("g1t_welcome=%E0%A4%A", "flagon-io"), false); | |
| 194 | assert.equal(welcomes("g1t_welcome=..%2F..%2Fx", "../../x"), false); | |
| 195 | assert.equal(welcomes(null, "flagon-io"), false); | |
| 196 | assert.match(clearWelcome(false), /^g1t_welcome=; Path=\/; Max-Age=0; HttpOnly; SameSite=Lax$/); | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 197 | }); |
| 198 | ||
| 199 | test("the two invites say which they are", () => { | |
| 200 | // A workspace's People page: an invitation to accept or decline, which signs up whoever has no account. | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 201 | const copy = workspaceInviteCopy("Flagon, Inc."); |
| 202 | assert.equal(copy.heading, "Invite to Flagon, Inc."); | |
| 203 | assert.match(copy.hint, /invitation to join Flagon, Inc\..*join only if they accept/); | |
| 204 | assert.match(copy.hint, /If they do not have a g1t account yet, they sign up from the invitation first/); | |
| 205 | assert.doesNotMatch(copy.hint, /one of yours|shared invites/); | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 206 | }); |
| 207 | ||
| 208 | test("an invite's page names the invite it is", () => { | |
| 209 | const base = { kind: "account" as const, invitedBy: { username: "syntaqx" }, workspace: null, repository: null, hasAccount: false }; | |
| 210 | const g1t = invitePageCopy(base, false); | |
| 211 | assert.equal(`${g1t.before}${g1t.place ?? ""}${g1t.after}`, "@syntaqx invited you to g1t"); | |
| Anyone on g1t can invite a friend again: Invite a friend, in the account menu with how many are left beside it, opens Settings → Invites to g1t with a dialog for their email address and a note. The friend gets an email from them whose link opens sign-up with the address filled in and, because it came from that email, confirmed; their account starts with a workspace of its own, and the page lists every invite sent with where it stands (Pending, confirming their email, Joined as @them), with Copy link, Send again and Revoke on each. It is word of mouth, never a gate: sign-ups stay open to everyone, nothing asks for an invite, and running out says friends can still sign up without one. | 212 | assert.match(g1t.about, /Sign up from this invite and your address is filled in for you\. It does not add you to anyone's workspace/); |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 213 | const join = invitePageCopy({ ...base, workspace: { name: "Flagon, Inc." } }, false); |
| 214 | assert.equal(`${join.before}${join.place}${join.after}`, "@syntaqx invited you to join Flagon, Inc. on g1t"); | |
| 215 | assert.equal(join.place, "Flagon, Inc."); | |
| 216 | assert.match(join.about, /invitation to join Flagon, Inc\., which you accept or decline/); | |
| 217 | assert.match(join.about, /You do not have a g1t account yet, so it also lets you make one/); | |
| 218 | // Someone with an account, or signed in, just accepts. | |
| 219 | assert.match(invitePageCopy({ ...base, kind: "workspace", workspace: { name: "Flagon, Inc." }, hasAccount: true }, false).about, /Accepting joins you to Flagon, Inc\./); | |
| 220 | assert.equal(invitePageCopy({ ...base, invitedBy: null }, false).before, "The g1t team invited you to g1t"); | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 221 | }); |
| Anyone on g1t can invite a friend again: Invite a friend, in the account menu with how many are left beside it, opens Settings → Invites to g1t with a dialog for their email address and a note. The friend gets an email from them whose link opens sign-up with the address filled in and, because it came from that email, confirmed; their account starts with a workspace of its own, and the page lists every invite sent with where it stands (Pending, confirming their email, Joined as @them), with Copy link, Send again and Revoke on each. It is word of mouth, never a gate: sign-ups stay open to everyone, nothing asks for an invite, and running out says friends can still sign up without one. | 222 | |
| 223 | test("Invite a friend never reads as a gate on signing up", () => { | |
| 224 | for (const words of Object.values(G1T_INVITES)) { | |
| 225 | assert.doesNotMatch(words, /invite-only|waitlist|request access|need an invite|held for/i, words); | |
| 226 | } | |
| 227 | assert.match(G1T_INVITES.about, /Anyone can sign up for g1t/); | |
| 228 | assert.match(G1T_INVITES.out, /can still sign up/); | |
| 229 | }); | |
| 230 | ||
| 231 | test("a friend can also be invited only to a paid workspace the person owns", () => { | |
| 232 | const memberships = [ | |
| 233 | { slug: "Acme", name: "Acme Labs", role: "owner" as const }, | |
| 234 | { slug: "hobby", name: null, role: "owner" as const }, | |
| 235 | { slug: "flagon-io", name: "Flagon, Inc.", role: "member" as const }, | |
| 236 | ]; | |
| 237 | assert.deepEqual(bringIntoChoices(memberships, ["hobby"]), [{ slug: "acme", name: "Acme Labs" }]); | |
| 238 | // Nothing paid that they own: no box at all. | |
| 239 | assert.deepEqual(bringIntoChoices(memberships, ["acme", "hobby"]), []); | |
| 240 | }); | |
| 241 | ||
| 242 | test("the form sends a workspace only when its box is ticked and it was offered", () => { | |
| 243 | const form = (fields: Record<string, string>) => new Map(Object.entries(fields)); | |
| 244 | const offered = [{ slug: "acme", name: "Acme Labs" }]; | |
| 245 | assert.deepEqual(inviteDraft(form({ email: " ada@example.com ", message: " " }), offered), { email: "ada@example.com", message: null }); | |
| 246 | assert.deepEqual(inviteDraft(form({ email: "ada@example.com", join: "acme", join_role: "owner" }), offered), { email: "ada@example.com", message: null }); | |
| 247 | assert.deepEqual(inviteDraft(form({ email: "ada@example.com", message: "Come build", also_join: "on", join: "acme", join_role: "owner" }), offered), { | |
| 248 | email: "ada@example.com", | |
| 249 | message: "Come build", | |
| 250 | join: "acme", | |
| 251 | joinRole: "owner", | |
| 252 | }); | |
| 253 | // A workspace not offered (free, or not theirs) is dropped. | |
| 254 | assert.deepEqual(inviteDraft(form({ email: "ada@example.com", also_join: "on", join: "hobby" }), offered), { email: "ada@example.com", message: null }); | |
| 255 | }); | |
| 256 | ||
| 257 | test("how many invites are left reads plainly, and the menu shows the count", () => { | |
| 258 | assert.equal(remainingLine({ limit: 20, remaining: 18 }), "18 of 20 invites left"); | |
| 259 | assert.equal(remainingLine({ limit: 20, remaining: 0 }), "You have used all 20 of your invites"); | |
| 260 | assert.equal(remainingLine({ limit: 1, remaining: 1 }), "1 of 1 invite left"); | |
| 261 | assert.equal(remainingLine({ limit: 0, remaining: 0 }), "Invites to g1t are turned off on this installation"); | |
| 262 | assert.equal(leftBadge(18), "18 left"); | |
| 263 | assert.equal(leftBadge(0), "0 left"); | |
| 264 | assert.equal(leftBadge(null), null); | |
| 265 | assert.equal(leftBadge(undefined), null); | |
| 266 | }); | |
| 267 | ||
| 268 | test("only a pending invite is sent again or copied; one waiting on its friend can still be revoked", () => { | |
| 269 | assert.deepEqual(inviteActions({ status: "pending", code: "g1t-abcd" }), { revoke: true, resend: true, copy: true }); | |
| 270 | assert.deepEqual(inviteActions({ status: "pending", code: null }), { revoke: true, resend: true, copy: false }); | |
| 271 | assert.deepEqual(inviteActions({ status: "awaiting_confirmation", code: null }), { revoke: true, resend: false, copy: false }); | |
| 272 | for (const status of ["redeemed", "expired", "revoked", "declined"] as const) { | |
| 273 | assert.deepEqual(inviteActions({ status, code: null }), { revoke: false, resend: false, copy: false }, status); | |
| 274 | } | |
| 275 | }); |
This file's history is long; its oldest lines are credited to the oldest commit read.