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

623 lines27,214 bytesCodeBlame
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 "redeemed":
141 return <Badge tone="mint">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}</Badge>;
142 case "expired":
143 return <Badge>Expired</Badge>;
144 case "revoked":
145 return <Badge tone="danger">Revoked</Badge>;
146 }
147}
148
149function SearchForm({ tab, query, placeholder, extra }: { tab: InviteTab; query: string; placeholder: string; extra?: React.ReactNode }) {
150 return (
151 <form method="get" className="flex flex-wrap items-end gap-2">
152 <input type="hidden" name="tab" value={tab} />
153 <div className="min-w-0 grow basis-60">
154 <Input name="q" defaultValue={query} placeholder={placeholder} aria-label="Search" />
155 </div>
156 {extra}
157 <Button type="submit" variant="quiet">
158 <Search size={14} />
159 Search
160 </Button>
161 </form>
162 );
163}
164
165/** A note for the invite email, folded away until wanted. */
166function NoteField({ form, label }: { form?: string; label: string }) {
167 return (
168 <details className="group/note">
169 <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">
170 <MessageSquareText size={13} />
171 {label}
172 </summary>
173 <div className="mt-2">
174 <Textarea
175 name="note"
176 form={form}
177 rows={2}
178 maxLength={MAX_NOTE}
179 placeholder="Optional. Goes in the invite email as a note from the g1t team."
180 aria-label="Note for the invite email"
181 />
182 </div>
183 </details>
184 );
185}
186
187/** Who decided, when, what they wrote, and whether the invite was used. */
188function Decision({ entry }: { entry: WaitlistEntry }) {
189 if (!entry.decidedBy) return null;
190 return (
191 <div className="space-y-1.5">
192 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-faint">
193 {entry.status === "invited" ? "Approved" : "Dismissed"} by <span className="font-mono text-muted">{entry.decidedBy}</span>
194 <When at={entry.decidedAt} />
195 {entry.status === "invited" &&
196 (entry.joinedAs ? <Badge tone="mint">Joined as @{entry.joinedAs}</Badge> : <Badge>Invite not used yet</Badge>)}
197 </p>
198 {entry.note && (
199 <p className="text-xs text-muted">
200 Note sent: <span className="whitespace-pre-line text-fg-soft">{entry.note}</span>
201 </p>
202 )}
203 </div>
204 );
205}
206
207function Waitlist({
208 entries,
209 query,
210 status,
211 selectAll,
212 actionData,
213}: {
214 entries: WaitlistEntry[];
215 query: string;
216 status: string;
217 selectAll: boolean;
218 actionData: ActionData;
219}) {
220 const { pathname, search } = useLocation();
221 const waiting = entries.filter((entry) => entry.status === "waiting");
222 const params = new URLSearchParams(search);
223 params.set("select", "all");
224 const selectHref = `${pathname}?${params}`;
225 params.delete("select");
226 const clearHref = `${pathname}?${params}`;
227 const bulkError = errorFor(actionData, { section: "bulk" });
228 return (
229 <div className="space-y-4">
230 <SearchForm
231 tab="waitlist"
232 query={query}
233 placeholder="Email or what they said"
234 extra={
235 <div className="w-full sm:w-40">
236 <Select name="status" defaultValue={status} aria-label="Status">
237 <option value="waiting">Waiting</option>
238 <option value="invited">Invited</option>
239 <option value="dismissed">Dismissed</option>
240 <option value="all">All</option>
241 </Select>
242 </div>
243 }
244 />
245 {waiting.length > 0 && (
246 // Ticked rows belong to this form through their `form` attribute:
247 // sudo runs no script, so choosing several is plain HTML.
248 <form
249 id="bulk"
250 method="post"
251 action={`${pathname}${search}`}
252 className="space-y-3 rounded-lg border border-line bg-raised/40 px-4 py-3 sm:px-5"
253 >
254 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
255 <p className="text-sm">
256 <span className="font-medium">{waiting.length} waiting</span>
257 <span className="text-muted"> · tick some, or </span>
258 {selectAll ? (
259 <a href={clearHref} className="text-merged hover:underline">
260 clear
261 </a>
262 ) : (
263 <a href={selectHref} className="text-merged hover:underline">
264 tick all {Math.min(waiting.length, MAX_BULK)}
265 </a>
266 )}
267 </p>
268 <div className="ml-auto flex flex-wrap gap-2">
269 <Button type="submit" name="intent" value="bulk-dismiss" variant="quiet">
270 <X size={14} />
271 Dismiss ticked
272 </Button>
273 <Button type="submit" name="intent" value="bulk-approve" variant="lavender">
274 <Check size={14} />
275 Approve and invite ticked
276 </Button>
277 </div>
278 </div>
279 <NoteField form="bulk" label="Add a note to every invite" />
280 {bulkError && <Notice tone="error">{bulkError}</Notice>}
281 </form>
282 )}
283 {entries.length === 0 ? (
284 <EmptyState title={status === "waiting" ? "Nobody waiting" : "Nothing here"}>
285 People who ask for access on g1t.sh/register show up here, newest first.
286 </EmptyState>
287 ) : (
288 <ul className="divide-y divide-line rounded-lg border border-line bg-surface">
289 {entries.map((entry, index) => {
290 const error = errorFor(actionData, { id: entry.id });
291 const open = entry.status === "waiting";
292 return (
293 <li key={entry.id} id={entry.id} className="scroll-mt-20 px-4 py-3 sm:px-5">
294 <div className="flex gap-3">
295 {open && (
296 <input
297 type="checkbox"
298 form="bulk"
299 name="ids"
300 value={entry.id}
301 defaultChecked={selectAll && index < MAX_BULK}
302 aria-label={`Tick ${entry.email}`}
303 className="mt-1 size-4 shrink-0 accent-[var(--color-merged)]"
304 />
305 )}
306 <div className="min-w-0 grow space-y-2">
307 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
308 <span className="font-mono text-sm break-all">{entry.email}</span>
309 {entry.status === "invited" && <Badge tone="mint">Invited</Badge>}
310 {entry.status === "dismissed" && <Badge>Dismissed</Badge>}
311 <span className="text-xs text-faint">
312 asked <When at={entry.createdAt} />
313 {entry.updatedAt !== entry.createdAt && (
314 <>
315 , again <When at={entry.updatedAt} />
316 </>
317 )}
318 </span>
319 </div>
320 {entry.about ? (
321 <blockquote className="border-l-2 border-line-strong pl-3 text-sm break-words whitespace-pre-line text-fg-soft">{entry.about}</blockquote>
322 ) : (
323 <p className="text-xs text-faint italic">Did not say what they will build.</p>
324 )}
325 <Decision entry={entry} />
326 {open && (
327 <form method="post" action={`${pathname}${search}#${entry.id}`} className="space-y-2">
328 <input type="hidden" name="id" value={entry.id} />
329 <div className="flex flex-wrap items-start justify-between gap-2">
330 <NoteField label="Add a note to the invite" />
331 <div className="ml-auto flex gap-2">
332 <Button type="submit" name="intent" value="dismiss" variant="quiet">
333 <X size={14} />
334 Dismiss
335 </Button>
336 <Button type="submit" name="intent" value="approve" variant="lavender">
337 <Check size={14} />
338 Approve and invite
339 </Button>
340 </div>
341 </div>
342 </form>
343 )}
344 {error && <Notice tone="error">{error}</Notice>}
345 </div>
346 </div>
347 </li>
348 );
349 })}
350 </ul>
351 )}
352 </div>
353 );
354}
355
356function InviteTable({ invites, actionData, revoke = true }: { invites: Invite[]; actionData: ActionData; revoke?: boolean }) {
357 const { pathname, search } = useLocation();
358 if (invites.length === 0) return <EmptyState title="No invites">Nothing matches.</EmptyState>;
359 return (
360 <div className="overflow-x-auto rounded-lg border border-line bg-surface">
361 <table className="w-full min-w-[38rem] text-sm">
362 <thead>
363 <tr className="text-left text-xs text-faint">
364 <th className="px-4 py-2 font-medium">Code</th>
365 <th className="px-4 py-2 font-medium">For</th>
366 <th className="px-4 py-2 font-medium">From</th>
367 <th className="px-4 py-2 font-medium">State</th>
368 <th className="px-4 py-2 font-medium">Made</th>
369 <th className="px-4 py-2" />
370 </tr>
371 </thead>
372 <tbody>
373 {invites.map((invite) => {
374 const error = errorFor(actionData, { id: invite.id });
375 return (
376 <tr key={invite.id} id={invite.id} className="scroll-mt-20 border-t border-line align-top">
377 <td className="px-4 py-2 font-mono text-xs whitespace-nowrap">{invite.hint}…</td>
378 <td className="px-4 py-2">
379 <span className="break-all">{invite.email ?? <span className="text-faint">anyone</span>}</span>
380 {invite.workspace && <span className="block text-xs text-faint">joins {invite.workspace}</span>}
381 </td>
382 <td className="px-4 py-2">
383 {invite.invitedBy ? (
384 <Link to={invitesHref("tree", { name: invite.invitedBy })} className="font-mono hover:underline">
385 {invite.invitedBy}
386 </Link>
387 ) : (
388 <span className="text-xs text-muted">staff{invite.staff ? ` · ${invite.staff}` : ""}</span>
389 )}
390 <span className="block text-xs text-faint">
391 {invite.chargedTo === "workspace" ? "workspace's invites" : invite.chargedTo === "user" ? "own invites" : "free"}
392 </span>
393 </td>
394 <td className="px-4 py-2">
395 {statusBadge(invite)}
396 {error && <p className="mt-1 text-xs text-danger">{error}</p>}
397 </td>
398 <td className="px-4 py-2 text-xs whitespace-nowrap text-muted">
399 <When at={invite.createdAt} />
400 </td>
401 <td className="px-4 py-2 text-right">
402 {revoke && invite.status === "pending" && (
403 <form method="post" action={`${pathname}${search}#${invite.id}`}>
404 <input type="hidden" name="id" value={invite.id} />
405 <Button type="submit" name="intent" value="revoke" variant="danger">
406 Revoke
407 </Button>
408 </form>
409 )}
410 </td>
411 </tr>
412 );
413 })}
414 </tbody>
415 </table>
416 </div>
417 );
418}
419
420function GrantAndMint({ actionData }: { actionData: ActionData }) {
421 const minted = actionData && "minted" in actionData ? actionData.minted : null;
422 return (
423 <div className="grid gap-5 lg:grid-cols-2">
424 <Section title="Grant invites" description="More invites for a person, or for a workspace whose owners share them. A negative number takes some back.">
425 <form method="post" className="space-y-3">
426 <input type="hidden" name="intent" value="grant" />
427 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
428 <Field label="To">
429 <Select name="target" defaultValue="user">
430 <option value="user">Person</option>
431 <option value="workspace">Workspace</option>
432 </Select>
433 </Field>
434 <Field label="Username or slug">
435 <Input name="name" required maxLength={40} placeholder="ada" />
436 </Field>
437 </div>
438 <Field label="Invites" hint="Up to 1000 at a time.">
439 <Input name="amount" required inputMode="numeric" placeholder="10" />
440 </Field>
441 <Field label="Why" hint="Kept with the grant.">
442 <Textarea name="note" rows={2} maxLength={500} required placeholder="e.g. Launch partner bringing their team." />
443 </Field>
444 {errorFor(actionData, { section: "grant" }) && <Notice tone="error">{errorFor(actionData, { section: "grant" })}</Notice>}
445 <div className="flex justify-end">
446 <Button type="submit" variant="lavender">
447 Grant
448 </Button>
449 </div>
450 </form>
451 </Section>
452 <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.">
453 <form method="post" className="space-y-3">
454 <input type="hidden" name="intent" value="mint" />
455 <Field label="Email (optional)">
456 <Input name="email" type="email" maxLength={254} placeholder="Anyone with the code" />
457 </Field>
458 {errorFor(actionData, { section: "mint" }) && <Notice tone="error">{errorFor(actionData, { section: "mint" })}</Notice>}
459 {minted?.code && (
460 <Notice tone="ok">
461 <p>{minted.email ? `Sent to ${minted.email}. ` : ""}Copy it now; it is not shown again.</p>
462 <p className="mt-1 font-mono text-xs break-all text-fg">https://g1t.sh/invite/{minted.code}</p>
463 </Notice>
464 )}
465 <div className="flex justify-end">
466 <Button type="submit">
467 <Ticket size={14} />
468 Mint
469 </Button>
470 </div>
471 </form>
472 </Section>
473 </div>
474 );
475}
476
477function Nodes({ nodes }: { nodes: InviteTreeNode[] }) {
478 if (nodes.length === 0) return null;
479 return (
480 <ul className="space-y-1 border-l border-line pl-4">
481 {nodes.map((node) => (
482 <li key={node.username}>
483 <Link to={invitesHref("tree", { name: node.username })} className="font-mono text-sm hover:underline">
484 {node.username}
485 </Link>{" "}
486 <span className="text-xs text-faint">
487 joined <When at={node.joinedAt} />
488 </span>
489 <div className="mt-1">
490 <Nodes nodes={node.invited} />
491 </div>
492 </li>
493 ))}
494 </ul>
495 );
496}
497
498function Tree({ tree, name, kind, actionData }: { tree: InviteTree | null; name: string; kind: string; actionData: ActionData }) {
499 return (
500 <div className="space-y-4">
501 <form method="get" className="flex flex-wrap items-end gap-2">
502 <input type="hidden" name="tab" value="tree" />
503 <div className="w-full sm:w-40">
504 <Select name="kind" defaultValue={kind} aria-label="Kind">
505 <option value="user">Person</option>
506 <option value="workspace">Workspace</option>
507 </Select>
508 </div>
509 <div className="min-w-0 grow basis-60">
510 <Input name="name" defaultValue={name} placeholder="Username or workspace slug" aria-label="Name" />
511 </div>
512 <Button type="submit" variant="quiet">
513 Look up
514 </Button>
515 </form>
516 {name && !tree && <EmptyState title={`No ${kind === "workspace" ? "workspace" : "account"} named ${name}`} />}
517 {tree && (
518 <div className="grid gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
519 <Section title={tree.username} description={kind === "workspace" ? "A workspace's shared invites" : "Where they came from, and whom they brought"}>
520 <dl className="space-y-2 text-sm">
521 {kind !== "workspace" && (
522 <div>
523 <dt className="text-xs text-faint">Invited by</dt>
524 <dd className="font-mono">
525 {tree.invitedBy.length > 0
526 ? tree.invitedBy.map((username, index) => (
527 <span key={username}>
528 {index > 0 && <span className="text-faint"> ← </span>}
529 <Link to={invitesHref("tree", { name: username })} className="hover:underline">
530 {username}
531 </Link>
532 </span>
533 ))
534 : tree.staff
535 ? `staff (${tree.staff})`
536 : "nobody: made without an invite"}
537 </dd>
538 </div>
539 )}
540 <div>
541 <dt className="text-xs text-faint">Allowance</dt>
542 <dd>
543 {tree.allowance.limit == null
544 ? `No limit · ${tree.allowance.used} out`
545 : `${tree.allowance.used} of ${tree.allowance.limit} used · ${tree.allowance.remaining} left`}
546 </dd>
547 </div>
548 {tree.grants.length > 0 && (
549 <div>
550 <dt className="text-xs text-faint">Grants</dt>
551 <dd>
552 <ul className="space-y-1">
553 {tree.grants.map((grant) => (
554 <li key={grant.createdAt} className="text-xs">
555 <span className="tabular font-medium">{grant.amount > 0 ? `+${grant.amount}` : grant.amount}</span> by{" "}
556 <span className="font-mono">{grant.grantedBy}</span> <When at={grant.createdAt} />
557 {grant.note && <span className="block text-muted">{grant.note}</span>}
558 </li>
559 ))}
560 </ul>
561 </dd>
562 </div>
563 )}
564 {kind !== "workspace" && (
565 <div>
566 <dt className="mb-1 text-xs text-faint">Invited</dt>
567 <dd>{tree.invited.length > 0 ? <Nodes nodes={tree.invited} /> : <span className="text-muted">Nobody yet</span>}</dd>
568 </div>
569 )}
570 </dl>
571 </Section>
572 <div className="min-w-0">
573 <h3 className="mb-2 text-sm font-medium">Invites</h3>
574 <InviteTable invites={tree.invites} actionData={actionData} />
575 </div>
576 </div>
577 )}
578 </div>
579 );
580}
581
582export default function Invites({ loaderData, actionData }: Route.ComponentProps) {
583 const { tab, query, status, waitlist, invites, tree, name, kind, error, done, selectAll } = loaderData;
584 return (
585 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
586 <PageHeader
587 title="Invites"
588 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."
589 />
590 <nav aria-label="Invites" className="mt-5 flex flex-wrap gap-2">
591 {INVITE_TABS.map((value) => (
592 <Link
593 key={value}
594 to={invitesHref(value)}
595 aria-current={value === tab ? "page" : undefined}
596 className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
597 value === tab ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg"
598 }`}
599 >
600 {TAB_LABEL[value]}
601 </Link>
602 ))}
603 </nav>
604 <div className="mt-5 space-y-3 empty:hidden">
605 {done && <Notice tone="ok">{done}</Notice>}
606 {error && <Notice tone="warn">Identity did not answer: {error}</Notice>}
607 </div>
608 <div className="mt-5">
609 {tab === "waitlist" && (
610 <Waitlist entries={waitlist} query={query} status={status} selectAll={selectAll} actionData={actionData} />
611 )}
612 {tab === "invites" && (
613 <div className="space-y-4">
614 <SearchForm tab="invites" query={query} placeholder="A code's start (g1t-k7m2), an email, or a username" />
615 <InviteTable invites={invites} actionData={actionData} />
616 </div>
617 )}
618 {tab === "grant" && <GrantAndMint actionData={actionData} />}
619 {tab === "tree" && <Tree tree={tree} name={name} kind={kind} actionData={actionData} />}
620 </div>
621 </main>
622 );
623}