g1t/apps/sudo/app/routes/invites.tsx
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 look | 1 | import { Check, Search, Ticket, X } from "lucide-react"; |
| 2 | import { Link, data, redirect, useLocation } from "react-router"; | |
| 3 | ||
| 4 | import type { Invite, InviteTree, InviteTreeNode, WaitlistEntry } from "@g1t/contracts"; | |
| 5 | ||
| 6 | import type { Route } from "./+types/invites"; | |
| 7 | import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Select, Textarea, When } from "~/components/ui"; | |
| 8 | import { text } from "~/lib/forms"; | |
| 9 | import { | |
| 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"; | |
| 20 | import { identity } from "~/lib/services.server"; | |
| 21 | import { settle } from "~/lib/settle"; | |
| 22 | import { requireStaff } from "~/lib/staff"; | |
| 23 | ||
| 24 | export const meta: Route.MetaFunction = () => [{ title: "Invites · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 25 | ||
| 26 | export 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 | ||
| 54 | export 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 | ||
| 95 | type ActionData = Route.ComponentProps["actionData"]; | |
| 96 | ||
| 97 | function 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 | ||
| 104 | function 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 | ||
| 117 | function 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 | ||
| 133 | function 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 | ||
| 203 | function 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 | ||
| 267 | function 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 | ||
| 324 | function 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 | ||
| 345 | function 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 | ||
| 429 | export 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 | } |