| 1 | import { Check, MessageSquareText, 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 | 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"; |
| 24 | import { identity } from "~/lib/services.server"; |
| 25 | import { settle } from "~/lib/settle"; |
| 26 | import { requireStaff } from "~/lib/staff"; |
| 27 | |
| 28 | export const meta: Route.MetaFunction = () => [{ title: "Invites · sudo" }, { name: "robots", content: "noindex, nofollow" }]; |
| 29 | |
| 30 | export 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 | |
| 60 | export 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 | |
| 127 | type ActionData = Route.ComponentProps["actionData"]; |
| 128 | |
| 129 | function 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 | |
| 136 | function 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 | |
| 149 | function 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. */ |
| 166 | function 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. */ |
| 188 | function 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 | |
| 207 | function 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 | |
| 356 | function 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 | |
| 420 | function 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 | |
| 477 | function 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 | |
| 498 | function 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 | |
| 582 | export 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 | } |