Skip to content
625 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.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1import { Check, MessageSquareText, Search, Ticket, X } from "lucide-react";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2import { 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 INVITE_TABS,
11 type InviteTab,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas12 MAX_BULK,
13 MAX_NOTE,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look14 TAB_LABEL,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas15 doneMessage,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look16 invitesHref,
17 parseGrant,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas18 parseIds,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look19 parseMintEmail,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas20 parseNote,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look21 parseTab,
22 parseWaitlistStatus,
23} from "~/lib/invites";
24import { identity } from "~/lib/services.server";
25import { settle } from "~/lib/settle";
26import { requireStaff } from "~/lib/staff";
27
28export const meta: Route.MetaFunction = () => [{ title: "Invites · sudo" }, { name: "robots", content: "noindex, nofollow" }];
29
30export async function loader({ request, context }: Route.LoaderArgs) {
31 requireStaff(context);
32 const url = new URL(request.url);
33 const tab = parseTab(url.searchParams.get("tab"));
34 const query = url.searchParams.get("q")?.trim() ?? "";
35 const status = parseWaitlistStatus(url.searchParams.get("status"));
36 const name = url.searchParams.get("name")?.trim().toLowerCase() ?? "";
37 const kind = url.searchParams.get("kind") === "workspace" ? "workspace" : "user";
38 const done = url.searchParams.get("done");
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas39 const selectAll = url.searchParams.get("select") === "all";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look40 const [waitlist, invites, tree] = await Promise.all([
41 tab === "waitlist" ? settle(identity.waitlist(query || null, status === "all" ? null : status)) : null,
42 tab === "invites" ? settle(identity.invites(query || null)) : null,
43 tab === "tree" && name ? settle(kind === "workspace" ? identity.workspaceInvites(name) : identity.inviteTree(name)) : null,
44 ]);
45 return {
46 tab,
47 query,
48 status,
49 name,
50 kind,
51 waitlist: waitlist?.ok ? waitlist.value : [],
52 invites: invites?.ok ? invites.value : [],
53 tree: tree?.ok ? tree.value : null,
54 error: [waitlist, invites, tree].map((result) => (result && !result.ok ? result.error : null)).find(Boolean) ?? null,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas55 done: doneMessage(done, url.searchParams.get("n")),
56 selectAll,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look57 };
58}
59
60export async function action({ request, context }: Route.ActionArgs) {
61 const staff = requireStaff(context);
62 const form = await request.formData();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas63 const back = (done: string, n?: number) => {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look64 const url = new URL(request.url);
65 url.searchParams.set("done", done);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas66 url.searchParams.delete("select");
67 if (n) url.searchParams.set("n", String(n));
68 else url.searchParams.delete("n");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look69 return redirect(`${url.pathname}${url.search}`);
70 };
71 switch (text(form, "intent")) {
72 case "approve":
73 case "dismiss": {
74 const approve = text(form, "intent") === "approve";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas75 const note = parseNote(text(form, "note"));
76 if (!note.ok) return data({ error: note.error, id: text(form, "id") }, { status: 422 });
77 const result = await identity.decideWaitlist(text(form, "id"), approve, staff.email, approve ? note.value : null);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look78 if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
79 throw back(approve ? "approved" : "dismissed");
80 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas81 case "bulk-approve":
82 case "bulk-dismiss": {
83 const approve = text(form, "intent") === "bulk-approve";
84 const ids = parseIds(form);
85 if (!ids.ok) return data({ error: ids.error, section: "bulk" }, { status: 422 });
86 const note = parseNote(text(form, "note"));
87 if (!note.ok) return data({ error: note.error, section: "bulk" }, { status: 422 });
88 // One at a time: each mints and emails its own invite.
89 const refused: string[] = [];
90 let decided = 0;
91 for (const id of ids.value) {
92 const result = await identity.decideWaitlist(id, approve, staff.email, approve ? note.value : null);
93 if (result.ok) decided += 1;
94 else refused.push(result.error.message);
95 }
96 if (refused.length > 0) {
97 const verb = approve ? "Approved" : "Dismissed";
98 return data({ error: `${verb} ${decided}; ${refused.length} not: ${refused.join(" ")}`, section: "bulk" }, { status: 422 });
99 }
100 throw back(approve ? "approved" : "dismissed", decided);
101 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look102 case "revoke": {
103 const result = await identity.revokeInvite(text(form, "id"), staff.email);
104 if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
105 throw back("revoked");
106 }
107 case "grant": {
108 const grant = parseGrant(form);
109 if (!grant.ok) return data({ error: grant.error, section: "grant" }, { status: 422 });
110 const { target, name, amount, note } = grant.value;
111 const result = await identity.grantInvites(target, name, amount, note, staff.email);
112 if (!result.ok) return data({ error: result.error.message, section: "grant" }, { status: 422 });
113 throw redirect(`${invitesHref("tree", { name, kind: target })}&done=granted`);
114 }
115 case "mint": {
116 const email = parseMintEmail(text(form, "email"));
117 if (!email.ok) return data({ error: email.error, section: "mint" }, { status: 422 });
118 const result = await identity.mintInvite(email.value, staff.email);
119 if (!result.ok) return data({ error: result.error.message, section: "mint" }, { status: 422 });
120 // Shown once, here: staff cannot see it again.
121 return { minted: result.value };
122 }
123 }
124 return data({ error: "Unknown action." }, { status: 400 });
125}
126
127type ActionData = Route.ComponentProps["actionData"];
128
129function errorFor(actionData: ActionData, key: { id?: string; section?: string }): string | null {
130 if (!actionData || !("error" in actionData)) return null;
131 if (key.id && "id" in actionData && actionData.id === key.id) return actionData.error;
132 if (key.section && "section" in actionData && actionData.section === key.section) return actionData.error;
133 return null;
134}
135
136function statusBadge(invite: Invite) {
137 switch (invite.status) {
138 case "pending":
139 return <Badge tone="lavender">Pending</Badge>;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)140 case "awaiting_confirmation":
141 return <Badge tone="lavender">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}, email not confirmed</Badge>;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look142 case "redeemed":
143 return <Badge tone="mint">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}</Badge>;
144 case "expired":
145 return <Badge>Expired</Badge>;
146 case "revoked":
147 return <Badge tone="danger">Revoked</Badge>;
148 }
149}
150
151function SearchForm({ tab, query, placeholder, extra }: { tab: InviteTab; query: string; placeholder: string; extra?: React.ReactNode }) {
152 return (
153 <form method="get" className="flex flex-wrap items-end gap-2">
154 <input type="hidden" name="tab" value={tab} />
155 <div className="min-w-0 grow basis-60">
156 <Input name="q" defaultValue={query} placeholder={placeholder} aria-label="Search" />
157 </div>
158 {extra}
159 <Button type="submit" variant="quiet">
160 <Search size={14} />
161 Search
162 </Button>
163 </form>
164 );
165}
166
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas167/** A note for the invite email, folded away until wanted. */
168function NoteField({ form, label }: { form?: string; label: string }) {
169 return (
170 <details className="group/note">
171 <summary className="inline-flex cursor-pointer list-none items-center gap-1.5 text-xs text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden">
172 <MessageSquareText size={13} />
173 {label}
174 </summary>
175 <div className="mt-2">
176 <Textarea
177 name="note"
178 form={form}
179 rows={2}
180 maxLength={MAX_NOTE}
181 placeholder="Optional. Goes in the invite email as a note from the g1t team."
182 aria-label="Note for the invite email"
183 />
184 </div>
185 </details>
186 );
187}
188
189/** Who decided, when, what they wrote, and whether the invite was used. */
190function Decision({ entry }: { entry: WaitlistEntry }) {
191 if (!entry.decidedBy) return null;
192 return (
193 <div className="space-y-1.5">
194 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-faint">
195 {entry.status === "invited" ? "Approved" : "Dismissed"} by <span className="font-mono text-muted">{entry.decidedBy}</span>
196 <When at={entry.decidedAt} />
197 {entry.status === "invited" &&
198 (entry.joinedAs ? <Badge tone="mint">Joined as @{entry.joinedAs}</Badge> : <Badge>Invite not used yet</Badge>)}
199 </p>
200 {entry.note && (
201 <p className="text-xs text-muted">
202 Note sent: <span className="whitespace-pre-line text-fg-soft">{entry.note}</span>
203 </p>
204 )}
205 </div>
206 );
207}
208
209function Waitlist({
210 entries,
211 query,
212 status,
213 selectAll,
214 actionData,
215}: {
216 entries: WaitlistEntry[];
217 query: string;
218 status: string;
219 selectAll: boolean;
220 actionData: ActionData;
221}) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look222 const { pathname, search } = useLocation();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas223 const waiting = entries.filter((entry) => entry.status === "waiting");
224 const params = new URLSearchParams(search);
225 params.set("select", "all");
226 const selectHref = `${pathname}?${params}`;
227 params.delete("select");
228 const clearHref = `${pathname}?${params}`;
229 const bulkError = errorFor(actionData, { section: "bulk" });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look230 return (
231 <div className="space-y-4">
232 <SearchForm
233 tab="waitlist"
234 query={query}
235 placeholder="Email or what they said"
236 extra={
237 <div className="w-full sm:w-40">
238 <Select name="status" defaultValue={status} aria-label="Status">
239 <option value="waiting">Waiting</option>
240 <option value="invited">Invited</option>
241 <option value="dismissed">Dismissed</option>
242 <option value="all">All</option>
243 </Select>
244 </div>
245 }
246 />
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas247 {waiting.length > 0 && (
248 // Ticked rows belong to this form through their `form` attribute:
249 // sudo runs no script, so choosing several is plain HTML.
250 <form
251 id="bulk"
252 method="post"
253 action={`${pathname}${search}`}
254 className="space-y-3 rounded-lg border border-line bg-raised/40 px-4 py-3 sm:px-5"
255 >
256 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
257 <p className="text-sm">
258 <span className="font-medium">{waiting.length} waiting</span>
259 <span className="text-muted"> · tick some, or </span>
260 {selectAll ? (
Merge branch 'worktree-agent-ad7c6d88d93adc817'261 <a href={clearHref} className="text-accent hover:underline">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas262 clear
263 </a>
264 ) : (
Merge branch 'worktree-agent-ad7c6d88d93adc817'265 <a href={selectHref} className="text-accent hover:underline">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas266 tick all {Math.min(waiting.length, MAX_BULK)}
267 </a>
268 )}
269 </p>
270 <div className="ml-auto flex flex-wrap gap-2">
271 <Button type="submit" name="intent" value="bulk-dismiss" variant="quiet">
272 <X size={14} />
273 Dismiss ticked
274 </Button>
275 <Button type="submit" name="intent" value="bulk-approve" variant="lavender">
276 <Check size={14} />
277 Approve and invite ticked
278 </Button>
279 </div>
280 </div>
281 <NoteField form="bulk" label="Add a note to every invite" />
282 {bulkError && <Notice tone="error">{bulkError}</Notice>}
283 </form>
284 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look285 {entries.length === 0 ? (
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas286 <EmptyState title={status === "waiting" ? "Nobody waiting" : "Nothing here"}>
287 People who ask for access on g1t.sh/register show up here, newest first.
288 </EmptyState>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look289 ) : (
290 <ul className="divide-y divide-line rounded-lg border border-line bg-surface">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas291 {entries.map((entry, index) => {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look292 const error = errorFor(actionData, { id: entry.id });
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas293 const open = entry.status === "waiting";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look294 return (
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas295 <li key={entry.id} id={entry.id} className="scroll-mt-20 px-4 py-3 sm:px-5">
296 <div className="flex gap-3">
297 {open && (
298 <input
299 type="checkbox"
300 form="bulk"
301 name="ids"
302 value={entry.id}
303 defaultChecked={selectAll && index < MAX_BULK}
304 aria-label={`Tick ${entry.email}`}
Merge branch 'worktree-agent-ad7c6d88d93adc817'305 className="mt-1 size-4 shrink-0 accent-[var(--color-accent)]"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas306 />
307 )}
308 <div className="min-w-0 grow space-y-2">
309 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
310 <span className="font-mono text-sm break-all">{entry.email}</span>
311 {entry.status === "invited" && <Badge tone="mint">Invited</Badge>}
312 {entry.status === "dismissed" && <Badge>Dismissed</Badge>}
313 <span className="text-xs text-faint">
314 asked <When at={entry.createdAt} />
315 {entry.updatedAt !== entry.createdAt && (
316 <>
317 , again <When at={entry.updatedAt} />
318 </>
319 )}
320 </span>
321 </div>
322 {entry.about ? (
323 <blockquote className="border-l-2 border-line-strong pl-3 text-sm break-words whitespace-pre-line text-fg-soft">{entry.about}</blockquote>
324 ) : (
325 <p className="text-xs text-faint italic">Did not say what they will build.</p>
326 )}
327 <Decision entry={entry} />
328 {open && (
329 <form method="post" action={`${pathname}${search}#${entry.id}`} className="space-y-2">
330 <input type="hidden" name="id" value={entry.id} />
331 <div className="flex flex-wrap items-start justify-between gap-2">
332 <NoteField label="Add a note to the invite" />
333 <div className="ml-auto flex gap-2">
334 <Button type="submit" name="intent" value="dismiss" variant="quiet">
335 <X size={14} />
336 Dismiss
337 </Button>
338 <Button type="submit" name="intent" value="approve" variant="lavender">
339 <Check size={14} />
340 Approve and invite
341 </Button>
342 </div>
343 </div>
344 </form>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look345 )}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas346 {error && <Notice tone="error">{error}</Notice>}
347 </div>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look348 </div>
349 </li>
350 );
351 })}
352 </ul>
353 )}
354 </div>
355 );
356}
357
358function InviteTable({ invites, actionData, revoke = true }: { invites: Invite[]; actionData: ActionData; revoke?: boolean }) {
359 const { pathname, search } = useLocation();
360 if (invites.length === 0) return <EmptyState title="No invites">Nothing matches.</EmptyState>;
361 return (
362 <div className="overflow-x-auto rounded-lg border border-line bg-surface">
363 <table className="w-full min-w-[38rem] text-sm">
364 <thead>
365 <tr className="text-left text-xs text-faint">
366 <th className="px-4 py-2 font-medium">Code</th>
367 <th className="px-4 py-2 font-medium">For</th>
368 <th className="px-4 py-2 font-medium">From</th>
369 <th className="px-4 py-2 font-medium">State</th>
370 <th className="px-4 py-2 font-medium">Made</th>
371 <th className="px-4 py-2" />
372 </tr>
373 </thead>
374 <tbody>
375 {invites.map((invite) => {
376 const error = errorFor(actionData, { id: invite.id });
377 return (
378 <tr key={invite.id} id={invite.id} className="scroll-mt-20 border-t border-line align-top">
379 <td className="px-4 py-2 font-mono text-xs whitespace-nowrap">{invite.hint}…</td>
380 <td className="px-4 py-2">
381 <span className="break-all">{invite.email ?? <span className="text-faint">anyone</span>}</span>
382 {invite.workspace && <span className="block text-xs text-faint">joins {invite.workspace}</span>}
383 </td>
384 <td className="px-4 py-2">
385 {invite.invitedBy ? (
386 <Link to={invitesHref("tree", { name: invite.invitedBy })} className="font-mono hover:underline">
387 {invite.invitedBy}
388 </Link>
389 ) : (
390 <span className="text-xs text-muted">staff{invite.staff ? ` · ${invite.staff}` : ""}</span>
391 )}
392 <span className="block text-xs text-faint">
393 {invite.chargedTo === "workspace" ? "workspace's invites" : invite.chargedTo === "user" ? "own invites" : "free"}
394 </span>
395 </td>
396 <td className="px-4 py-2">
397 {statusBadge(invite)}
398 {error && <p className="mt-1 text-xs text-danger">{error}</p>}
399 </td>
400 <td className="px-4 py-2 text-xs whitespace-nowrap text-muted">
401 <When at={invite.createdAt} />
402 </td>
403 <td className="px-4 py-2 text-right">
404 {revoke && invite.status === "pending" && (
405 <form method="post" action={`${pathname}${search}#${invite.id}`}>
406 <input type="hidden" name="id" value={invite.id} />
407 <Button type="submit" name="intent" value="revoke" variant="danger">
408 Revoke
409 </Button>
410 </form>
411 )}
412 </td>
413 </tr>
414 );
415 })}
416 </tbody>
417 </table>
418 </div>
419 );
420}
421
422function GrantAndMint({ actionData }: { actionData: ActionData }) {
423 const minted = actionData && "minted" in actionData ? actionData.minted : null;
424 return (
425 <div className="grid gap-5 lg:grid-cols-2">
426 <Section title="Grant invites" description="More invites for a person, or for a workspace whose owners share them. A negative number takes some back.">
427 <form method="post" className="space-y-3">
428 <input type="hidden" name="intent" value="grant" />
429 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
430 <Field label="To">
431 <Select name="target" defaultValue="user">
432 <option value="user">Person</option>
433 <option value="workspace">Workspace</option>
434 </Select>
435 </Field>
436 <Field label="Username or slug">
437 <Input name="name" required maxLength={40} placeholder="ada" />
438 </Field>
439 </div>
440 <Field label="Invites" hint="Up to 1000 at a time.">
441 <Input name="amount" required inputMode="numeric" placeholder="10" />
442 </Field>
443 <Field label="Why" hint="Kept with the grant.">
444 <Textarea name="note" rows={2} maxLength={500} required placeholder="e.g. Launch partner bringing their team." />
445 </Field>
446 {errorFor(actionData, { section: "grant" }) && <Notice tone="error">{errorFor(actionData, { section: "grant" })}</Notice>}
447 <div className="flex justify-end">
448 <Button type="submit" variant="lavender">
449 Grant
450 </Button>
451 </div>
452 </form>
453 </Section>
454 <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.">
455 <form method="post" className="space-y-3">
456 <input type="hidden" name="intent" value="mint" />
457 <Field label="Email (optional)">
458 <Input name="email" type="email" maxLength={254} placeholder="Anyone with the code" />
459 </Field>
460 {errorFor(actionData, { section: "mint" }) && <Notice tone="error">{errorFor(actionData, { section: "mint" })}</Notice>}
461 {minted?.code && (
462 <Notice tone="ok">
463 <p>{minted.email ? `Sent to ${minted.email}. ` : ""}Copy it now; it is not shown again.</p>
464 <p className="mt-1 font-mono text-xs break-all text-fg">https://g1t.sh/invite/{minted.code}</p>
465 </Notice>
466 )}
467 <div className="flex justify-end">
468 <Button type="submit">
469 <Ticket size={14} />
470 Mint
471 </Button>
472 </div>
473 </form>
474 </Section>
475 </div>
476 );
477}
478
479function Nodes({ nodes }: { nodes: InviteTreeNode[] }) {
480 if (nodes.length === 0) return null;
481 return (
482 <ul className="space-y-1 border-l border-line pl-4">
483 {nodes.map((node) => (
484 <li key={node.username}>
485 <Link to={invitesHref("tree", { name: node.username })} className="font-mono text-sm hover:underline">
486 {node.username}
487 </Link>{" "}
488 <span className="text-xs text-faint">
489 joined <When at={node.joinedAt} />
490 </span>
491 <div className="mt-1">
492 <Nodes nodes={node.invited} />
493 </div>
494 </li>
495 ))}
496 </ul>
497 );
498}
499
500function Tree({ tree, name, kind, actionData }: { tree: InviteTree | null; name: string; kind: string; actionData: ActionData }) {
501 return (
502 <div className="space-y-4">
503 <form method="get" className="flex flex-wrap items-end gap-2">
504 <input type="hidden" name="tab" value="tree" />
505 <div className="w-full sm:w-40">
506 <Select name="kind" defaultValue={kind} aria-label="Kind">
507 <option value="user">Person</option>
508 <option value="workspace">Workspace</option>
509 </Select>
510 </div>
511 <div className="min-w-0 grow basis-60">
512 <Input name="name" defaultValue={name} placeholder="Username or workspace slug" aria-label="Name" />
513 </div>
514 <Button type="submit" variant="quiet">
515 Look up
516 </Button>
517 </form>
518 {name && !tree && <EmptyState title={`No ${kind === "workspace" ? "workspace" : "account"} named ${name}`} />}
519 {tree && (
520 <div className="grid gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
521 <Section title={tree.username} description={kind === "workspace" ? "A workspace's shared invites" : "Where they came from, and whom they brought"}>
522 <dl className="space-y-2 text-sm">
523 {kind !== "workspace" && (
524 <div>
525 <dt className="text-xs text-faint">Invited by</dt>
526 <dd className="font-mono">
527 {tree.invitedBy.length > 0
528 ? tree.invitedBy.map((username, index) => (
529 <span key={username}>
530 {index > 0 && <span className="text-faint"> ← </span>}
531 <Link to={invitesHref("tree", { name: username })} className="hover:underline">
532 {username}
533 </Link>
534 </span>
535 ))
536 : tree.staff
537 ? `staff (${tree.staff})`
538 : "nobody: made without an invite"}
539 </dd>
540 </div>
541 )}
542 <div>
543 <dt className="text-xs text-faint">Allowance</dt>
544 <dd>
545 {tree.allowance.limit == null
546 ? `No limit · ${tree.allowance.used} out`
547 : `${tree.allowance.used} of ${tree.allowance.limit} used · ${tree.allowance.remaining} left`}
548 </dd>
549 </div>
550 {tree.grants.length > 0 && (
551 <div>
552 <dt className="text-xs text-faint">Grants</dt>
553 <dd>
554 <ul className="space-y-1">
555 {tree.grants.map((grant) => (
556 <li key={grant.createdAt} className="text-xs">
557 <span className="tabular font-medium">{grant.amount > 0 ? `+${grant.amount}` : grant.amount}</span> by{" "}
558 <span className="font-mono">{grant.grantedBy}</span> <When at={grant.createdAt} />
559 {grant.note && <span className="block text-muted">{grant.note}</span>}
560 </li>
561 ))}
562 </ul>
563 </dd>
564 </div>
565 )}
566 {kind !== "workspace" && (
567 <div>
568 <dt className="mb-1 text-xs text-faint">Invited</dt>
569 <dd>{tree.invited.length > 0 ? <Nodes nodes={tree.invited} /> : <span className="text-muted">Nobody yet</span>}</dd>
570 </div>
571 )}
572 </dl>
573 </Section>
574 <div className="min-w-0">
575 <h3 className="mb-2 text-sm font-medium">Invites</h3>
576 <InviteTable invites={tree.invites} actionData={actionData} />
577 </div>
578 </div>
579 )}
580 </div>
581 );
582}
583
584export default function Invites({ loaderData, actionData }: Route.ComponentProps) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas585 const { tab, query, status, waitlist, invites, tree, name, kind, error, done, selectAll } = loaderData;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look586 return (
587 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
588 <PageHeader
589 title="Invites"
590 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."
591 />
592 <nav aria-label="Invites" className="mt-5 flex flex-wrap gap-2">
593 {INVITE_TABS.map((value) => (
594 <Link
595 key={value}
596 to={invitesHref(value)}
597 aria-current={value === tab ? "page" : undefined}
598 className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
Merge branch 'worktree-agent-ad7c6d88d93adc817'599 value === tab ? "border-accent/50 bg-accent/10 text-accent" : "border-line text-muted hover:border-line-strong hover:text-fg"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look600 }`}
601 >
602 {TAB_LABEL[value]}
603 </Link>
604 ))}
605 </nav>
606 <div className="mt-5 space-y-3 empty:hidden">
607 {done && <Notice tone="ok">{done}</Notice>}
608 {error && <Notice tone="warn">Identity did not answer: {error}</Notice>}
609 </div>
610 <div className="mt-5">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas611 {tab === "waitlist" && (
612 <Waitlist entries={waitlist} query={query} status={status} selectAll={selectAll} actionData={actionData} />
613 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look614 {tab === "invites" && (
615 <div className="space-y-4">
616 <SearchForm tab="invites" query={query} placeholder="A code's start (g1t-k7m2), an email, or a username" />
617 <InviteTable invites={invites} actionData={actionData} />
618 </div>
619 )}
620 {tab === "grant" && <GrantAndMint actionData={actionData} />}
621 {tab === "tree" && <Tree tree={tree} name={name} kind={kind} actionData={actionData} />}
622 </div>
623 </main>
624 );
625}

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