Skip to content
363 linesCodeBlameRaw

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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1import assert from "node:assert/strict";
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)2import { readFileSync } from "node:fs";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look3import { test } from "node:test";
4
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)5import { ACCOUNT_SETTINGS } from "./account-settings.ts";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look6import { CONTACT } from "./legal.ts";
7import {
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)8 G1T_INVITES,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look9 HAVE_AN_INVITE,
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)10 bringIntoChoices,
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.11 filterInvitations,
12 filterMembers,
13 invitationCounts,
14 invitationFilter,
15 invitationFilterOf,
16 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)17 inviteDraft,
18 inviteKind,
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.19 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)20 invitePageCopy,
21 invitesPage,
22 peoplePages,
23 workspaceInviteCopy,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look24 INVITES_CONTACT,
25 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 confirm26 cleanProof,
27 invitePath,
28 inviteSignUpCopy,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look29 inviteFor,
30 inviteLink,
31 inviteState,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas32 landingFor,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look33 looksAutomated,
34 moreInvitesMailto,
35 remainingLine,
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)36 sharedDomainsHint,
37 sharedInviteLine,
38 sharedInviteLink,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look39 signUpCopy,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas40 suggestUsername,
41 welcomeCookie,
42 clearWelcome,
43 welcomes,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look44} from "./invites.ts";
45
46const CODE = "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk";
47
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.48test("the Invitations page sorts every state under one filter, and counts them", () => {
49 const statuses = ["pending", "awaiting_confirmation", "awaiting_answer", "redeemed", "declined", "expired", "revoked"] as const;
50 const invites = statuses.map((status) => ({ status }));
51 assert.equal(invitationFilterOf("pending"), "pending");
52 assert.equal(invitationFilterOf("awaiting_confirmation"), "pending");
53 assert.equal(invitationFilterOf("awaiting_answer"), "pending");
54 assert.equal(invitationFilterOf("redeemed"), "accepted");
55 assert.equal(invitationFilterOf("declined"), "declined");
56 assert.equal(invitationFilterOf("expired"), "expired");
57 assert.equal(invitationFilterOf("revoked"), "revoked");
58 assert.deepEqual(invitationCounts(invites), { all: 7, pending: 3, accepted: 1, declined: 1, expired: 1, revoked: 1 });
59 assert.equal(filterInvitations(invites, "all").length, 7);
60 assert.deepEqual(filterInvitations(invites, "pending").map((invite) => invite.status), ["pending", "awaiting_confirmation", "awaiting_answer"]);
61 assert.deepEqual(filterInvitations(invites, "accepted").map((invite) => invite.status), ["redeemed"]);
62 // The query names a filter, or means All.
63 assert.equal(invitationFilter("pending"), "pending");
64 assert.equal(invitationFilter("failed"), "all");
65 assert.equal(invitationFilter(null), "all");
66 // Waiting is amber, joined green, a no red, and over is quiet.
67 assert.equal(invitationTone("awaiting_answer"), "warn");
68 assert.equal(invitationTone("redeemed"), "success");
69 assert.equal(invitationTone("revoked"), "danger");
70 assert.equal(invitationTone("expired"), "neutral");
71});
72
73test("the Members page filters by role, two-factor, team and words", () => {
74 const members = [
75 { username: "ada", name: "Ada Lovelace", role: "owner" as const, two_factor: true },
76 { username: "grace", name: "Grace Hopper", role: "member" as const, two_factor: false },
77 { username: "linus", name: null, role: "member" as const, two_factor: true },
78 ];
79 const teams = { ada: [{ slug: "core" }], grace: [{ slug: "web" }, { slug: "core" }] };
80 assert.deepEqual(memberFilters(""), { role: "all", twoFactor: "all", team: null, query: "" });
81 assert.deepEqual(memberFilters("?role=owner&2fa=off&team=Core&q=%20ada%20"), { role: "owner", twoFactor: "off", team: "core", query: "ada" });
82 assert.deepEqual(memberFilters("?role=admin&2fa=maybe"), { role: "all", twoFactor: "all", team: null, query: "" });
83 const names = (filters: string) => filterMembers(members, memberFilters(filters), teams).map((member) => member.username);
84 assert.deepEqual(names(""), ["ada", "grace", "linus"]);
85 assert.deepEqual(names("?role=member"), ["grace", "linus"]);
86 assert.deepEqual(names("?2fa=off"), ["grace"]);
87 assert.deepEqual(names("?team=core"), ["ada", "grace"]);
88 assert.deepEqual(names("?q=hopper"), ["grace"]);
89 assert.deepEqual(names("?q=@LIN"), ["linus"]);
90 assert.deepEqual(names("?role=owner&team=web"), []);
91});
92
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look93test("while invite-only, nobody is offered a plain sign-up", () => {
Sign-up buttons say Sign up everywhere; only the sign-up page says registration takes an invite94 // Sign up everywhere; only the sign-up page says registration takes an invite.
95 assert.deepEqual(signUpCopy(), { primary: "Sign up", secondary: null });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look96 assert.equal(HAVE_AN_INVITE, "/register#invite");
97});
98
99test("asking for more invites goes to support with the [g1t Invites] subject", () => {
100 assert.equal(INVITES_CONTACT, CONTACT.support);
101 assert.equal(moreInvitesMailto(), "mailto:hey@flagon.io?subject=%5Bg1t%20Invites%5D%20More%20invites");
102 assert.equal(
103 moreInvitesMailto("acme"),
104 "mailto:hey@flagon.io?subject=%5Bg1t%20Invites%5D%20More%20invites%20for%20acme",
105 );
106});
107
108test("an invite link is on g1t.sh unless told otherwise", () => {
109 assert.equal(inviteLink(CODE), `https://g1t.sh/invite/${CODE}`);
110 assert.equal(inviteLink(CODE, "http://localhost:8787/"), `http://localhost:8787/invite/${CODE}`);
111});
112
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 confirm113const PROOF = "4f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c8";
114
115test("an invite email's proof is kept only when it looks like one, and goes along to the invite's page", () => {
116 assert.equal(cleanProof(PROOF), PROOF);
117 assert.equal(cleanProof(` ${PROOF.toUpperCase()} `), PROOF);
118 assert.equal(cleanProof("not-a-proof"), null);
119 assert.equal(cleanProof("abc"), null);
120 assert.equal(cleanProof("a".repeat(500)), null);
121 assert.equal(cleanProof(null), null);
122 assert.equal(invitePath(CODE, PROOF), `/invite/${CODE}?proof=${PROOF}`);
123 assert.equal(invitePath(CODE, null), `/invite/${CODE}`);
124 assert.equal(invitePath(CODE), `/invite/${CODE}`);
125});
126
127test("signing up from the invite email says the address is confirmed already; otherwise the code step applies", () => {
128 const base = { address: "ada@example.com", emailProven: false, workspace: { name: "Flagon, Inc." }, repository: null };
129 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)130 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 confirm131 assert.match(proven.confirmed ?? "", /^ada@example\.com is confirmed: you came here from the invite we emailed to it/);
132 assert.match(proven.hint, /confirmed already/);
133 assert.doesNotMatch(proven.hint, /code/);
134
135 // No proof (a code typed in, or a link passed on): nothing new is said.
136 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)137 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 confirm138 assert.equal(plain.confirmed, null);
139 assert.equal(plain.hint, "Your invite was sent here. We email it a code to confirm it before you start.");
140
141 // An invite for anyone with the code has no address to prove.
142 const open = inviteSignUpCopy({ ...base, address: null, emailProven: true, workspace: null });
143 assert.equal(open.confirmed, null);
144 assert.equal(open.intro, "It takes a minute.");
145 assert.equal(open.hint, "We email it a code to confirm it before you start.");
146
147 const repo = inviteSignUpCopy({ ...base, workspace: null, repository: { name: "flagon-io/g1t" }, emailProven: true });
148 assert.equal(repo.intro, "You get flagon-io/g1t as soon as you create it.");
149});
150
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look151test("a pasted link or code is tidied to the code", () => {
152 assert.equal(cleanCode(CODE), CODE);
153 assert.equal(cleanCode(` ${CODE} `), CODE);
154 assert.equal(cleanCode(`https://g1t.sh/invite/${CODE}`), CODE);
155 assert.equal(cleanCode(`https://g1t.sh/register?invite=${CODE}&next=%2F`), CODE);
156 assert.equal(cleanCode("g1t-k7m2 q9xd"), "g1t-k7m2q9xd");
157 assert.equal(cleanCode(null), "");
158 assert.equal(cleanCode("x".repeat(500)).length, 80);
159});
160
161test("each invite says where it stands and whom it is for", () => {
162 const base = { redeemedBy: null, email: null, workspace: null };
163 assert.deepEqual(inviteState({ ...base, status: "pending" }), { label: "Pending", tone: "pending" });
164 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)165 assert.deepEqual(inviteState({ ...base, status: "awaiting_confirmation", redeemedBy: "ada" }), {
166 label: "@ada is confirming their email",
167 tone: "pending",
168 });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look169 assert.deepEqual(inviteState({ ...base, status: "expired" }), { label: "Expired", tone: "dead" });
170 assert.deepEqual(inviteState({ ...base, status: "revoked" }), { label: "Revoked", tone: "dead" });
171 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)172 assert.equal(inviteFor({ ...base, status: "pending", email: "ada@example.com", workspace: "acme" }), "ada@example.com");
173 assert.equal(inviteFor({ ...base, status: "pending", invitee: "daweazl", workspace: "flagon-io" }), "@daweazl");
174 // Which kind each is, beside whom it is for.
175 assert.deepEqual(inviteKind({ workspace: null }), { kind: "g1t", label: "Invite to g1t" });
176 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)177 assert.deepEqual(inviteState({ ...base, status: "awaiting_answer", redeemedBy: "daweazl" }), { label: "Waiting for @daweazl to accept", tone: "pending" });
178 assert.deepEqual(inviteState({ ...base, status: "declined", invitee: "daweazl" }), { label: "@daweazl declined", tone: "dead" });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look179});
180
181test("what is left reads plainly", () => {
182 assert.equal(remainingLine({ limit: 5, used: 2, remaining: 3 }), "3 of 5 invites left");
183 assert.equal(remainingLine({ limit: 1, used: 0, remaining: 1 }), "1 of 1 invite left");
184 assert.equal(remainingLine({ limit: 5, used: 5, remaining: 0 }), "You have used all 5 of your invites");
185 assert.equal(remainingLine({ limit: null, used: 40, remaining: null }), "No limit on your invites");
186});
187
188test("bots that fill the hidden field or answer instantly are turned away", () => {
189 const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null });
190 const now = 1_000_000;
191 assert.equal(looksAutomated(form({ website: "http://spam.example" }), now), true);
192 assert.equal(looksAutomated(form({ started: String(now - 200) }), now), true);
193 assert.equal(looksAutomated(form({ started: String(now - 10_000) }), now), false);
194 assert.equal(looksAutomated(form({}), now), false);
195 assert.equal(looksAutomated(form({ website: " " }), now), false);
196});
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas197
198test("a username is suggested from the invited address", () => {
199 assert.equal(suggestUsername("ada.lovelace@example.com"), "ada-lovelace");
200 assert.equal(suggestUsername("Margaret_Hamilton+g1t@example.com"), "margaret-hamilton");
201 assert.equal(suggestUsername("--x--@example.com"), "x");
202 assert.equal(suggestUsername(`${"a".repeat(38)}.b@example.com`), "a".repeat(38));
203 assert.equal(suggestUsername("...@example.com"), "");
204 assert.equal(suggestUsername(null), "");
205});
206
207test("an invite lands in its workspace, else its repository", () => {
208 assert.equal(landingFor({ workspace: { slug: "Flagon-IO" }, repository: null }), "flagon-io");
209 assert.equal(landingFor({ workspace: null, repository: { name: "flagon-io/g1t" } }), "flagon-io/g1t");
210 assert.equal(landingFor({ workspace: null, repository: null }), null);
211});
212
213test("the welcome is for one place, and ends", () => {
214 const set = welcomeCookie("flagon-io/g1t", true);
215 assert.match(set, /^g1t_welcome=flagon-io%2Fg1t; Path=\/; Max-Age=300; HttpOnly; SameSite=Lax; Secure$/);
216 const header = `a=1; ${set.split(";")[0]}; b=2`;
217 assert.equal(welcomes(header, "flagon-io/g1t"), true);
218 assert.equal(welcomes(header, "flagon-io"), false);
219 assert.equal(welcomes("g1t_welcome=flagon-io", "Flagon-IO"), true);
220 assert.equal(welcomes("g1t_welcome=%E0%A4%A", "flagon-io"), false);
221 assert.equal(welcomes("g1t_welcome=..%2F..%2Fx", "../../x"), false);
222 assert.equal(welcomes(null, "flagon-io"), false);
223 assert.match(clearWelcome(false), /^g1t_welcome=; Path=\/; Max-Age=0; HttpOnly; SameSite=Lax$/);
224});
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)225
226test("a shared invite link names its group above the sign-up form", () => {
227 assert.equal(sharedInviteLine("Cloudflare judges"), "Invited as part of Cloudflare judges");
228 assert.equal(sharedInviteLine(" Hacker News readers "), "Invited as part of Hacker News readers");
229 // A one-person invite has no group, and says nothing of the kind.
230 assert.equal(sharedInviteLine(null), null);
231 assert.equal(sharedInviteLine(undefined), null);
232 assert.equal(sharedInviteLine(" "), null);
233});
234
235test("a shared invite link is sign-up with its code filled in", () => {
236 assert.equal(sharedInviteLink(CODE), `https://g1t.sh/register?invite=${CODE}`);
237 assert.equal(sharedInviteLink(CODE, "http://localhost:5173/"), `http://localhost:5173/register?invite=${CODE}`);
238 // The register page reads the code back out of its own link.
239 assert.equal(cleanCode(sharedInviteLink(CODE)), CODE);
240});
241
242test("a shared link limited to domains says which, on the email field", () => {
243 assert.equal(sharedDomainsHint([]), undefined);
244 assert.equal(sharedDomainsHint(null), undefined);
245 assert.equal(sharedDomainsHint(["cloudflare.com"]), "This invite is for addresses at cloudflare.com. Use yours there.");
246 assert.equal(
247 sharedDomainsHint(["a.com", "b.com", "c.com"]),
248 "This invite is for addresses at a.com, b.com or c.com. Use yours there.",
249 );
250});
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)251
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)252test("an invite to g1t can also invite its person to a workspace you own that can add people, never chosen for you", () => {
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)253 const memberships = [
254 { slug: "flagon-io", name: "Flagon, Inc.", role: "owner" as const },
255 { slug: "side", name: "side", role: "owner" as const },
256 { slug: "friends", name: "Friends", role: "member" as const },
257 ];
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)258 // Free ones and ones you only belong to are not offered; nothing is chosen, not even the current one.
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)259 const here = bringIntoChoices(memberships, ["side"], "flagon-io");
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)260 assert.deepEqual(here, { options: [{ slug: "flagon-io", name: "Flagon, Inc." }], note: null });
261 assert.equal("chosen" in here, false);
262 // In a free workspace: not offered, and the form says why.
263 assert.match(bringIntoChoices(memberships, ["side"], "side").note ?? "", /side is on the free plan, so it cannot add people/);
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)264 // In one you are only a member of.
265 assert.match(bringIntoChoices(memberships, [], "friends").note ?? "", /Only the owners of friends/);
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)266 assert.deepEqual(bringIntoChoices([], [], null), { options: [], note: null });
267});
268
269/** A submitted form, as `inviteDraft` reads it. */
270const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null });
271
272test("an invite to g1t sends no workspace unless its box is ticked", () => {
273 // Off by default: a form without the box sends no `join`, even with a workspace left in it.
274 assert.deepEqual(inviteDraft(form({ intent: "create-invite", email: " ada@example.com ", charge: "mine" })), {
275 email: "ada@example.com",
276 workspace: null,
277 });
278 const untickedButFilled = inviteDraft(form({ email: "", join: "flagon-io", join_role: "owner" }));
279 assert.equal("join" in untickedButFilled, false);
280 assert.equal("joinRole" in untickedButFilled, false);
281 assert.equal(untickedButFilled.email, null);
282 // Ticked: the workspace and its role go with it.
283 assert.deepEqual(inviteDraft(form({ also_join: "on", join: "flagon-io", join_role: "owner", charge: "flagon-io" })), {
284 email: null,
285 workspace: "flagon-io",
286 join: "flagon-io",
287 joinRole: "owner",
288 });
289 // Ticked with no workspace chosen: still none. Any role but owner is member.
290 assert.equal("join" in inviteDraft(form({ also_join: "on", join: "" })), false);
291 assert.equal(inviteDraft(form({ also_join: "on", join: "acme", join_role: "admin" })).joinRole, "member");
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)292});
293
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)294test("the invites form keeps the workspace behind an unticked box", () => {
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)295 const section = readFileSync(new URL("../components/invites-section.tsx", import.meta.url), "utf8");
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)296 assert.match(section, /useState\(false\)/);
297 assert.match(section, /name="also_join"/);
298 // The workspace and role fields are drawn only once the box is ticked, so nothing else is sent.
299 assert.match(section, /\{alsoJoin && \(\s*<div[^]*?name="join"[^]*?name="join_role"/);
Chat controls, public profiles, shadcn selects, and no Docs tab in a project300 // No workspace is chosen for them: the select starts on its placeholder, and is required.
301 assert.match(section, /name="join"\s*required\s*placeholder="Choose a 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)302 assert.doesNotMatch(section, /bringInto\.chosen|Bring them into/);
303});
304
305test("invites to g1t are made only while sign-up takes one; after that only the list stays", () => {
306 assert.deepEqual(invitesPage("invite", 0), { form: true, listed: true });
307 assert.deepEqual(invitesPage(null, 0), { form: true, listed: true });
308 // Open: no form; the menus list the page only with invites to look back on.
309 assert.deepEqual(invitesPage("open", 0), { form: false, listed: false });
310 assert.deepEqual(invitesPage("open", 3), { form: false, listed: true });
311 assert.match(G1T_INVITES.open, /^Anyone can sign up for g1t now/);
312 assert.match(G1T_INVITES.open, /workspace's People page/);
313});
314
315test("the two invites say which they are", () => {
316 // Settings → Invites: an account, and no workspace.
317 assert.equal(G1T_INVITES.heading, "Invite people to g1t");
318 assert.equal(ACCOUNT_SETTINGS.invites.heading, G1T_INVITES.heading);
319 assert.equal(ACCOUNT_SETTINGS.invites.title, G1T_INVITES.nav);
320 assert.equal(ACCOUNT_SETTINGS.invites.about, G1T_INVITES.about);
321 assert.match(G1T_INVITES.about, /lets one person make an account\. It does not add them to any workspace/);
322 assert.equal(G1T_INVITES.alsoJoin, "Also invite them to a workspace");
323 // A workspace's People page: an invitation to accept or decline, which signs up whoever has no account.
324 const closed = workspaceInviteCopy("Flagon, Inc.", true);
325 assert.equal(closed.heading, "Invite to Flagon, Inc.");
326 assert.match(closed.hint, /invitation to join Flagon, Inc\..*join only if they accept/);
327 assert.match(closed.hint, /If they do not have a g1t account yet, the invitation also lets them sign up/);
328 assert.equal(closed.elsewhere, "To invite someone to g1t without adding them to Flagon, Inc., use Settings → Invites.");
329 // Once anyone can sign up, there is no invite to g1t to point to.
330 const open = workspaceInviteCopy("Flagon, Inc.", false);
331 assert.equal(open.elsewhere, null);
332 assert.doesNotMatch(open.hint, /one of yours/);
333 // Settings → Invites points to the People pages of the workspaces you own, the current one first.
334 assert.deepEqual(
335 peoplePages(
336 [
337 { slug: "side", name: null, role: "owner" },
338 { slug: "Flagon-io", name: "Flagon, Inc.", role: "owner" },
339 { slug: "friends", name: "Friends", role: "member" },
340 ],
341 "flagon-io",
342 ),
343 [
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.344 { slug: "flagon-io", name: "Flagon, Inc.", to: "/flagon-io/-/members" },
345 { slug: "side", name: "side", to: "/side/-/members" },
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)346 ],
347 );
348});
349
350test("an invite's page names the invite it is", () => {
351 const base = { kind: "account" as const, invitedBy: { username: "syntaqx" }, workspace: null, repository: null, hasAccount: false };
352 const g1t = invitePageCopy(base, false);
353 assert.equal(`${g1t.before}${g1t.place ?? ""}${g1t.after}`, "@syntaqx invited you to g1t");
354 assert.match(g1t.about, /lets you make an account\. It does not add you to anyone's workspace/);
355 const join = invitePageCopy({ ...base, workspace: { name: "Flagon, Inc." } }, false);
356 assert.equal(`${join.before}${join.place}${join.after}`, "@syntaqx invited you to join Flagon, Inc. on g1t");
357 assert.equal(join.place, "Flagon, Inc.");
358 assert.match(join.about, /invitation to join Flagon, Inc\., which you accept or decline/);
359 assert.match(join.about, /You do not have a g1t account yet, so it also lets you make one/);
360 // Someone with an account, or signed in, just accepts.
361 assert.match(invitePageCopy({ ...base, kind: "workspace", workspace: { name: "Flagon, Inc." }, hasAccount: true }, false).about, /Accepting joins you to Flagon, Inc\./);
362 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)363});

This file's history is long; its oldest lines are credited to the oldest commit read.