g1t/apps/sudo/app/routes/invites.tsx
| 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 | } |