flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/apps/sudo/app/routes/invites.tsx

468 lines21,064 bytesCodeBlame

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 { Check, Search, Ticket, X } from "lucide-react";
2import { Link, data, redirect, useLocation } from "react-router";
3
4import type { Invite, InviteTree, InviteTreeNode, WaitlistEntry } from "@g1t/contracts";
5
6import type { Route } from "./+types/invites";
7import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Select, Textarea, When } from "~/components/ui";
8import { text } from "~/lib/forms";
9import {
10 INVITES_DONE,
11 INVITE_TABS,
12 type InviteTab,
13 TAB_LABEL,
14 invitesHref,
15 parseGrant,
16 parseMintEmail,
17 parseTab,
18 parseWaitlistStatus,
19} from "~/lib/invites";
20import { identity } from "~/lib/services.server";
21import { settle } from "~/lib/settle";
22import { requireStaff } from "~/lib/staff";
23
24export const meta: Route.MetaFunction = () => [{ title: "Invites · sudo" }, { name: "robots", content: "noindex, nofollow" }];
25
26export async function loader({ request, context }: Route.LoaderArgs) {
27 requireStaff(context);
28 const url = new URL(request.url);
29 const tab = parseTab(url.searchParams.get("tab"));
30 const query = url.searchParams.get("q")?.trim() ?? "";
31 const status = parseWaitlistStatus(url.searchParams.get("status"));
32 const name = url.searchParams.get("name")?.trim().toLowerCase() ?? "";
33 const kind = url.searchParams.get("kind") === "workspace" ? "workspace" : "user";
34 const done = url.searchParams.get("done");
35 const [waitlist, invites, tree] = await Promise.all([
36 tab === "waitlist" ? settle(identity.waitlist(query || null, status === "all" ? null : status)) : null,
37 tab === "invites" ? settle(identity.invites(query || null)) : null,
38 tab === "tree" && name ? settle(kind === "workspace" ? identity.workspaceInvites(name) : identity.inviteTree(name)) : null,
39 ]);
40 return {
41 tab,
42 query,
43 status,
44 name,
45 kind,
46 waitlist: waitlist?.ok ? waitlist.value : [],
47 invites: invites?.ok ? invites.value : [],
48 tree: tree?.ok ? tree.value : null,
49 error: [waitlist, invites, tree].map((result) => (result && !result.ok ? result.error : null)).find(Boolean) ?? null,
50 done: done ? (INVITES_DONE[done] ?? null) : null,
51 };
52}
53
54export async function action({ request, context }: Route.ActionArgs) {
55 const staff = requireStaff(context);
56 const form = await request.formData();
57 const back = (done: string) => {
58 const url = new URL(request.url);
59 url.searchParams.set("done", done);
60 return redirect(`${url.pathname}${url.search}`);
61 };
62 switch (text(form, "intent")) {
63 case "approve":
64 case "dismiss": {
65 const approve = text(form, "intent") === "approve";
66 const result = await identity.decideWaitlist(text(form, "id"), approve, staff.email);
67 if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
68 throw back(approve ? "approved" : "dismissed");
69 }
70 case "revoke": {
71 const result = await identity.revokeInvite(text(form, "id"), staff.email);
72 if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
73 throw back("revoked");
74 }
75 case "grant": {
76 const grant = parseGrant(form);
77 if (!grant.ok) return data({ error: grant.error, section: "grant" }, { status: 422 });
78 const { target, name, amount, note } = grant.value;
79 const result = await identity.grantInvites(target, name, amount, note, staff.email);
80 if (!result.ok) return data({ error: result.error.message, section: "grant" }, { status: 422 });
81 throw redirect(`${invitesHref("tree", { name, kind: target })}&done=granted`);
82 }
83 case "mint": {
84 const email = parseMintEmail(text(form, "email"));
85 if (!email.ok) return data({ error: email.error, section: "mint" }, { status: 422 });
86 const result = await identity.mintInvite(email.value, staff.email);
87 if (!result.ok) return data({ error: result.error.message, section: "mint" }, { status: 422 });
88 // Shown once, here: staff cannot see it again.
89 return { minted: result.value };
90 }
91 }
92 return data({ error: "Unknown action." }, { status: 400 });
93}
94
95type ActionData = Route.ComponentProps["actionData"];
96
97function errorFor(actionData: ActionData, key: { id?: string; section?: string }): string | null {
98 if (!actionData || !("error" in actionData)) return null;
99 if (key.id && "id" in actionData && actionData.id === key.id) return actionData.error;
100 if (key.section && "section" in actionData && actionData.section === key.section) return actionData.error;
101 return null;
102}
103
104function statusBadge(invite: Invite) {
105 switch (invite.status) {
106 case "pending":
107 return <Badge tone="lavender">Pending</Badge>;
108 case "redeemed":
109 return <Badge tone="mint">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}</Badge>;
110 case "expired":
111 return <Badge>Expired</Badge>;
112 case "revoked":
113 return <Badge tone="danger">Revoked</Badge>;
114 }
115}
116
117function SearchForm({ tab, query, placeholder, extra }: { tab: InviteTab; query: string; placeholder: string; extra?: React.ReactNode }) {
118 return (
119 <form method="get" className="flex flex-wrap items-end gap-2">
120 <input type="hidden" name="tab" value={tab} />
121 <div className="min-w-0 grow basis-60">
122 <Input name="q" defaultValue={query} placeholder={placeholder} aria-label="Search" />
123 </div>
124 {extra}
125 <Button type="submit" variant="quiet">
126 <Search size={14} />
127 Search
128 </Button>
129 </form>
130 );
131}
132
133function Waitlist({ entries, query, status, actionData }: { entries: WaitlistEntry[]; query: string; status: string; actionData: ActionData }) {
134 const { pathname, search } = useLocation();
135 return (
136 <div className="space-y-4">
137 <SearchForm
138 tab="waitlist"
139 query={query}
140 placeholder="Email or what they said"
141 extra={
142 <div className="w-full sm:w-40">
143 <Select name="status" defaultValue={status} aria-label="Status">
144 <option value="waiting">Waiting</option>
145 <option value="invited">Invited</option>
146 <option value="dismissed">Dismissed</option>
147 <option value="all">All</option>
148 </Select>
149 </div>
150 }
151 />
152 {entries.length === 0 ? (
153 <EmptyState title="Nobody waiting">People who ask for access on g1t.sh/register show up here, oldest first.</EmptyState>
154 ) : (
155 <ul className="divide-y divide-line rounded-lg border border-line bg-surface">
156 {entries.map((entry) => {
157 const error = errorFor(actionData, { id: entry.id });
158 return (
159 <li key={entry.id} id={entry.id} className="scroll-mt-20 space-y-2 px-4 py-3 sm:px-5">
160 <div className="flex flex-wrap items-center gap-2">
161 <span className="font-mono text-sm break-all">{entry.email}</span>
162 {entry.status === "invited" && <Badge tone="mint">Invited</Badge>}
163 {entry.status === "dismissed" && <Badge>Dismissed</Badge>}
164 <span className="text-xs text-faint">
165 asked <When at={entry.createdAt} />
166 {entry.updatedAt !== entry.createdAt && (
167 <>
168 , again <When at={entry.updatedAt} />
169 </>
170 )}
171 </span>
172 {entry.status === "waiting" && (
173 <form method="post" action={`${pathname}${search}#${entry.id}`} className="ml-auto flex gap-2">
174 <input type="hidden" name="id" value={entry.id} />
175 <Button type="submit" name="intent" value="dismiss" variant="quiet">
176 <X size={14} />
177 Dismiss
178 </Button>
179 <Button type="submit" name="intent" value="approve" variant="lavender">
180 <Check size={14} />
181 Approve and invite
182 </Button>
183 </form>
184 )}
185 </div>
186 {entry.about && <blockquote className="border-l-2 border-line-strong pl-3 text-sm break-words whitespace-pre-line text-fg-soft">{entry.about}</blockquote>}
187 {entry.decidedBy && (
188 <p className="text-xs text-faint">
189 {entry.status === "invited" ? "Approved" : "Dismissed"} by <span className="font-mono">{entry.decidedBy}</span>{" "}
190 <When at={entry.decidedAt} />
191 </p>
192 )}
193 {error && <Notice tone="error">{error}</Notice>}
194 </li>
195 );
196 })}
197 </ul>
198 )}
199 </div>
200 );
201}
202
203function InviteTable({ invites, actionData, revoke = true }: { invites: Invite[]; actionData: ActionData; revoke?: boolean }) {
204 const { pathname, search } = useLocation();
205 if (invites.length === 0) return <EmptyState title="No invites">Nothing matches.</EmptyState>;
206 return (
207 <div className="overflow-x-auto rounded-lg border border-line bg-surface">
208 <table className="w-full min-w-[38rem] text-sm">
209 <thead>
210 <tr className="text-left text-xs text-faint">
211 <th className="px-4 py-2 font-medium">Code</th>
212 <th className="px-4 py-2 font-medium">For</th>
213 <th className="px-4 py-2 font-medium">From</th>
214 <th className="px-4 py-2 font-medium">State</th>
215 <th className="px-4 py-2 font-medium">Made</th>
216 <th className="px-4 py-2" />
217 </tr>
218 </thead>
219 <tbody>
220 {invites.map((invite) => {
221 const error = errorFor(actionData, { id: invite.id });
222 return (
223 <tr key={invite.id} id={invite.id} className="scroll-mt-20 border-t border-line align-top">
224 <td className="px-4 py-2 font-mono text-xs whitespace-nowrap">{invite.hint}…</td>
225 <td className="px-4 py-2">
226 <span className="break-all">{invite.email ?? <span className="text-faint">anyone</span>}</span>
227 {invite.workspace && <span className="block text-xs text-faint">joins {invite.workspace}</span>}
228 </td>
229 <td className="px-4 py-2">
230 {invite.invitedBy ? (
231 <Link to={invitesHref("tree", { name: invite.invitedBy })} className="font-mono hover:underline">
232 {invite.invitedBy}
233 </Link>
234 ) : (
235 <span className="text-xs text-muted">staff{invite.staff ? ` · ${invite.staff}` : ""}</span>
236 )}
237 <span className="block text-xs text-faint">
238 {invite.chargedTo === "workspace" ? "workspace's invites" : invite.chargedTo === "user" ? "own invites" : "free"}
239 </span>
240 </td>
241 <td className="px-4 py-2">
242 {statusBadge(invite)}
243 {error && <p className="mt-1 text-xs text-danger">{error}</p>}
244 </td>
245 <td className="px-4 py-2 text-xs whitespace-nowrap text-muted">
246 <When at={invite.createdAt} />
247 </td>
248 <td className="px-4 py-2 text-right">
249 {revoke && invite.status === "pending" && (
250 <form method="post" action={`${pathname}${search}#${invite.id}`}>
251 <input type="hidden" name="id" value={invite.id} />
252 <Button type="submit" name="intent" value="revoke" variant="danger">
253 Revoke
254 </Button>
255 </form>
256 )}
257 </td>
258 </tr>
259 );
260 })}
261 </tbody>
262 </table>
263 </div>
264 );
265}
266
267function GrantAndMint({ actionData }: { actionData: ActionData }) {
268 const minted = actionData && "minted" in actionData ? actionData.minted : null;
269 return (
270 <div className="grid gap-5 lg:grid-cols-2">
271 <Section title="Grant invites" description="More invites for a person, or for a workspace whose owners share them. A negative number takes some back.">
272 <form method="post" className="space-y-3">
273 <input type="hidden" name="intent" value="grant" />
274 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
275 <Field label="To">
276 <Select name="target" defaultValue="user">
277 <option value="user">Person</option>
278 <option value="workspace">Workspace</option>
279 </Select>
280 </Field>
281 <Field label="Username or slug">
282 <Input name="name" required maxLength={40} placeholder="ada" />
283 </Field>
284 </div>
285 <Field label="Invites" hint="Up to 1000 at a time.">
286 <Input name="amount" required inputMode="numeric" placeholder="10" />
287 </Field>
288 <Field label="Why" hint="Kept with the grant.">
289 <Textarea name="note" rows={2} maxLength={500} required placeholder="e.g. Launch partner bringing their team." />
290 </Field>
291 {errorFor(actionData, { section: "grant" }) && <Notice tone="error">{errorFor(actionData, { section: "grant" })}</Notice>}
292 <div className="flex justify-end">
293 <Button type="submit" variant="lavender">
294 Grant
295 </Button>
296 </div>
297 </form>
298 </Section>
299 <Section title="Mint an invite" description="An invite that uses nobody's allowance. With an email, it is sent there and only that address can use it.">
300 <form method="post" className="space-y-3">
301 <input type="hidden" name="intent" value="mint" />
302 <Field label="Email (optional)">
303 <Input name="email" type="email" maxLength={254} placeholder="Anyone with the code" />
304 </Field>
305 {errorFor(actionData, { section: "mint" }) && <Notice tone="error">{errorFor(actionData, { section: "mint" })}</Notice>}
306 {minted?.code && (
307 <Notice tone="ok">
308 <p>{minted.email ? `Sent to ${minted.email}. ` : ""}Copy it now; it is not shown again.</p>
309 <p className="mt-1 font-mono text-xs break-all text-fg">https://g1t.sh/invite/{minted.code}</p>
310 </Notice>
311 )}
312 <div className="flex justify-end">
313 <Button type="submit">
314 <Ticket size={14} />
315 Mint
316 </Button>
317 </div>
318 </form>
319 </Section>
320 </div>
321 );
322}
323
324function Nodes({ nodes }: { nodes: InviteTreeNode[] }) {
325 if (nodes.length === 0) return null;
326 return (
327 <ul className="space-y-1 border-l border-line pl-4">
328 {nodes.map((node) => (
329 <li key={node.username}>
330 <Link to={invitesHref("tree", { name: node.username })} className="font-mono text-sm hover:underline">
331 {node.username}
332 </Link>{" "}
333 <span className="text-xs text-faint">
334 joined <When at={node.joinedAt} />
335 </span>
336 <div className="mt-1">
337 <Nodes nodes={node.invited} />
338 </div>
339 </li>
340 ))}
341 </ul>
342 );
343}
344
345function Tree({ tree, name, kind, actionData }: { tree: InviteTree | null; name: string; kind: string; actionData: ActionData }) {
346 return (
347 <div className="space-y-4">
348 <form method="get" className="flex flex-wrap items-end gap-2">
349 <input type="hidden" name="tab" value="tree" />
350 <div className="w-full sm:w-40">
351 <Select name="kind" defaultValue={kind} aria-label="Kind">
352 <option value="user">Person</option>
353 <option value="workspace">Workspace</option>
354 </Select>
355 </div>
356 <div className="min-w-0 grow basis-60">
357 <Input name="name" defaultValue={name} placeholder="Username or workspace slug" aria-label="Name" />
358 </div>
359 <Button type="submit" variant="quiet">
360 Look up
361 </Button>
362 </form>
363 {name && !tree && <EmptyState title={`No ${kind === "workspace" ? "workspace" : "account"} named ${name}`} />}
364 {tree && (
365 <div className="grid gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
366 <Section title={tree.username} description={kind === "workspace" ? "A workspace's shared invites" : "Where they came from, and whom they brought"}>
367 <dl className="space-y-2 text-sm">
368 {kind !== "workspace" && (
369 <div>
370 <dt className="text-xs text-faint">Invited by</dt>
371 <dd className="font-mono">
372 {tree.invitedBy.length > 0
373 ? tree.invitedBy.map((username, index) => (
374 <span key={username}>
375 {index > 0 && <span className="text-faint"> ← </span>}
376 <Link to={invitesHref("tree", { name: username })} className="hover:underline">
377 {username}
378 </Link>
379 </span>
380 ))
381 : tree.staff
382 ? `staff (${tree.staff})`
383 : "nobody: made without an invite"}
384 </dd>
385 </div>
386 )}
387 <div>
388 <dt className="text-xs text-faint">Allowance</dt>
389 <dd>
390 {tree.allowance.limit == null
391 ? `No limit · ${tree.allowance.used} out`
392 : `${tree.allowance.used} of ${tree.allowance.limit} used · ${tree.allowance.remaining} left`}
393 </dd>
394 </div>
395 {tree.grants.length > 0 && (
396 <div>
397 <dt className="text-xs text-faint">Grants</dt>
398 <dd>
399 <ul className="space-y-1">
400 {tree.grants.map((grant) => (
401 <li key={grant.createdAt} className="text-xs">
402 <span className="tabular font-medium">{grant.amount > 0 ? `+${grant.amount}` : grant.amount}</span> by{" "}
403 <span className="font-mono">{grant.grantedBy}</span> <When at={grant.createdAt} />
404 {grant.note && <span className="block text-muted">{grant.note}</span>}
405 </li>
406 ))}
407 </ul>
408 </dd>
409 </div>
410 )}
411 {kind !== "workspace" && (
412 <div>
413 <dt className="mb-1 text-xs text-faint">Invited</dt>
414 <dd>{tree.invited.length > 0 ? <Nodes nodes={tree.invited} /> : <span className="text-muted">Nobody yet</span>}</dd>
415 </div>
416 )}
417 </dl>
418 </Section>
419 <div className="min-w-0">
420 <h3 className="mb-2 text-sm font-medium">Invites</h3>
421 <InviteTable invites={tree.invites} actionData={actionData} />
422 </div>
423 </div>
424 )}
425 </div>
426 );
427}
428
429export default function Invites({ loaderData, actionData }: Route.ComponentProps) {
430 const { tab, query, status, waitlist, invites, tree, name, kind, error, done } = loaderData;
431 return (
432 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
433 <PageHeader
434 title="Invites"
435 description="g1t.sh is invite-only. Approve people from the waitlist, find and revoke invites, grant more to a person or a workspace, and trace who brought whom."
436 />
437 <nav aria-label="Invites" className="mt-5 flex flex-wrap gap-2">
438 {INVITE_TABS.map((value) => (
439 <Link
440 key={value}
441 to={invitesHref(value)}
442 aria-current={value === tab ? "page" : undefined}
443 className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
444 value === tab ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg"
445 }`}
446 >
447 {TAB_LABEL[value]}
448 </Link>
449 ))}
450 </nav>
451 <div className="mt-5 space-y-3 empty:hidden">
452 {done && <Notice tone="ok">{done}</Notice>}
453 {error && <Notice tone="warn">Identity did not answer: {error}</Notice>}
454 </div>
455 <div className="mt-5">
456 {tab === "waitlist" && <Waitlist entries={waitlist} query={query} status={status} actionData={actionData} />}
457 {tab === "invites" && (
458 <div className="space-y-4">
459 <SearchForm tab="invites" query={query} placeholder="A code's start (g1t-k7m2), an email, or a username" />
460 <InviteTable invites={invites} actionData={actionData} />
461 </div>
462 )}
463 {tab === "grant" && <GrantAndMint actionData={actionData} />}
464 {tab === "tree" && <Tree tree={tree} name={name} kind={kind} actionData={actionData} />}
465 </div>
466 </main>
467 );
468}