Skip to content
625 linesCodeBlameRaw
1import { Check, MessageSquareText, 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 INVITE_TABS,
11 type InviteTab,
12 MAX_BULK,
13 MAX_NOTE,
14 TAB_LABEL,
15 doneMessage,
16 invitesHref,
17 parseGrant,
18 parseIds,
19 parseMintEmail,
20 parseNote,
21 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");
39 const selectAll = url.searchParams.get("select") === "all";
40 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,
55 done: doneMessage(done, url.searchParams.get("n")),
56 selectAll,
57 };
58}
59
60export async function action({ request, context }: Route.ActionArgs) {
61 const staff = requireStaff(context);
62 const form = await request.formData();
63 const back = (done: string, n?: number) => {
64 const url = new URL(request.url);
65 url.searchParams.set("done", done);
66 url.searchParams.delete("select");
67 if (n) url.searchParams.set("n", String(n));
68 else url.searchParams.delete("n");
69 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";
75 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);
78 if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
79 throw back(approve ? "approved" : "dismissed");
80 }
81 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 }
102 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>;
140 case "awaiting_confirmation":
141 return <Badge tone="lavender">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}, email not confirmed</Badge>;
142 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
167/** 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}) {
222 const { pathname, search } = useLocation();
223 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" });
230 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 />
247 {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 ? (
261 <a href={clearHref} className="text-accent hover:underline">
262 clear
263 </a>
264 ) : (
265 <a href={selectHref} className="text-accent hover:underline">
266 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 )}
285 {entries.length === 0 ? (
286 <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>
289 ) : (
290 <ul className="divide-y divide-line rounded-lg border border-line bg-surface">
291 {entries.map((entry, index) => {
292 const error = errorFor(actionData, { id: entry.id });
293 const open = entry.status === "waiting";
294 return (
295 <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}`}
305 className="mt-1 size-4 shrink-0 accent-[var(--color-accent)]"
306 />
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>
345 )}
346 {error && <Notice tone="error">{error}</Notice>}
347 </div>
348 </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) {
585 const { tab, query, status, waitlist, invites, tree, name, kind, error, done, selectAll } = loaderData;
586 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 ${
599 value === tab ? "border-accent/50 bg-accent/10 text-accent" : "border-line text-muted hover:border-line-strong hover:text-fg"
600 }`}
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">
611 {tab === "waitlist" && (
612 <Waitlist entries={waitlist} query={query} status={status} selectAll={selectAll} actionData={actionData} />
613 )}
614 {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}