| 1 | import { Check, Link2, MessageSquareText, Search, Ticket, X } from "lucide-react"; |
| 2 | import { Link, data, redirect, useLocation } from "react-router"; |
| 3 | |
| 4 | import type { Invite, InviteTree, InviteTreeNode, SharedInvite, 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 | MAX_SHARED_DOMAINS, |
| 15 | MAX_SHARED_LABEL, |
| 16 | MAX_SHARED_USES, |
| 17 | SHARED_MAX_DAYS, |
| 18 | SHARED_TTL_DAYS, |
| 19 | TAB_LABEL, |
| 20 | dayAfter, |
| 21 | domainsLine, |
| 22 | doneMessage, |
| 23 | invitesHref, |
| 24 | joinedThrough, |
| 25 | parseGrant, |
| 26 | parseIds, |
| 27 | parseMintEmail, |
| 28 | parseNote, |
| 29 | parseSharedInvite, |
| 30 | parseTab, |
| 31 | parseWaitlistStatus, |
| 32 | sharedInviteLink, |
| 33 | sharedStatus, |
| 34 | usesLine, |
| 35 | } from "~/lib/invites"; |
| 36 | import { identity } from "~/lib/services.server"; |
| 37 | import { settle } from "~/lib/settle"; |
| 38 | import { requireStaff } from "~/lib/staff"; |
| 39 | |
| 40 | export const meta: Route.MetaFunction = () => [{ title: "Invites · sudo" }, { name: "robots", content: "noindex, nofollow" }]; |
| 41 | |
| 42 | export async function loader({ request, context }: Route.LoaderArgs) { |
| 43 | requireStaff(context); |
| 44 | const url = new URL(request.url); |
| 45 | const tab = parseTab(url.searchParams.get("tab")); |
| 46 | const query = url.searchParams.get("q")?.trim() ?? ""; |
| 47 | const status = parseWaitlistStatus(url.searchParams.get("status")); |
| 48 | const name = url.searchParams.get("name")?.trim().toLowerCase() ?? ""; |
| 49 | const kind = url.searchParams.get("kind") === "workspace" ? "workspace" : "user"; |
| 50 | const done = url.searchParams.get("done"); |
| 51 | const selectAll = url.searchParams.get("select") === "all"; |
| 52 | const [waitlist, invites, tree, shared] = await Promise.all([ |
| 53 | tab === "waitlist" ? settle(identity.waitlist(query || null, status === "all" ? null : status)) : null, |
| 54 | tab === "invites" ? settle(identity.invites(query || null)) : null, |
| 55 | tab === "tree" && name ? settle(kind === "workspace" ? identity.workspaceInvites(name) : identity.inviteTree(name)) : null, |
| 56 | tab === "shared" ? settle(identity.sharedInvites()) : null, |
| 57 | ]); |
| 58 | const now = Date.now(); |
| 59 | return { |
| 60 | tab, |
| 61 | query, |
| 62 | status, |
| 63 | name, |
| 64 | kind, |
| 65 | waitlist: waitlist?.ok ? waitlist.value : [], |
| 66 | invites: invites?.ok ? invites.value : [], |
| 67 | tree: tree?.ok ? tree.value : null, |
| 68 | shared: shared?.ok ? shared.value : [], |
| 69 | // The date inputs' default and bounds: 14 days, from today to a year ahead (UTC). |
| 70 | expiry: { default: dayAfter(now, SHARED_TTL_DAYS), min: dayAfter(now, 0), max: dayAfter(now, SHARED_MAX_DAYS) }, |
| 71 | error: [waitlist, invites, tree, shared].map((result) => (result && !result.ok ? result.error : null)).find(Boolean) ?? null, |
| 72 | done: doneMessage(done, url.searchParams.get("n")), |
| 73 | selectAll, |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | export async function action({ request, context }: Route.ActionArgs) { |
| 78 | const staff = requireStaff(context); |
| 79 | const form = await request.formData(); |
| 80 | const back = (done: string, n?: number) => { |
| 81 | const url = new URL(request.url); |
| 82 | url.searchParams.set("done", done); |
| 83 | url.searchParams.delete("select"); |
| 84 | if (n) url.searchParams.set("n", String(n)); |
| 85 | else url.searchParams.delete("n"); |
| 86 | return redirect(`${url.pathname}${url.search}`); |
| 87 | }; |
| 88 | switch (text(form, "intent")) { |
| 89 | case "approve": |
| 90 | case "dismiss": { |
| 91 | const approve = text(form, "intent") === "approve"; |
| 92 | const note = parseNote(text(form, "note")); |
| 93 | if (!note.ok) return data({ error: note.error, id: text(form, "id") }, { status: 422 }); |
| 94 | const result = await identity.decideWaitlist(text(form, "id"), approve, staff.email, approve ? note.value : null); |
| 95 | if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 }); |
| 96 | throw back(approve ? "approved" : "dismissed"); |
| 97 | } |
| 98 | case "bulk-approve": |
| 99 | case "bulk-dismiss": { |
| 100 | const approve = text(form, "intent") === "bulk-approve"; |
| 101 | const ids = parseIds(form); |
| 102 | if (!ids.ok) return data({ error: ids.error, section: "bulk" }, { status: 422 }); |
| 103 | const note = parseNote(text(form, "note")); |
| 104 | if (!note.ok) return data({ error: note.error, section: "bulk" }, { status: 422 }); |
| 105 | // One at a time: each mints and emails its own invite. |
| 106 | const refused: string[] = []; |
| 107 | let decided = 0; |
| 108 | for (const id of ids.value) { |
| 109 | const result = await identity.decideWaitlist(id, approve, staff.email, approve ? note.value : null); |
| 110 | if (result.ok) decided += 1; |
| 111 | else refused.push(result.error.message); |
| 112 | } |
| 113 | if (refused.length > 0) { |
| 114 | const verb = approve ? "Approved" : "Dismissed"; |
| 115 | return data({ error: `${verb} ${decided}; ${refused.length} not: ${refused.join(" ")}`, section: "bulk" }, { status: 422 }); |
| 116 | } |
| 117 | throw back(approve ? "approved" : "dismissed", decided); |
| 118 | } |
| 119 | case "revoke": { |
| 120 | const result = await identity.revokeInvite(text(form, "id"), staff.email); |
| 121 | if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 }); |
| 122 | throw back("revoked"); |
| 123 | } |
| 124 | case "grant": { |
| 125 | const grant = parseGrant(form); |
| 126 | if (!grant.ok) return data({ error: grant.error, section: "grant" }, { status: 422 }); |
| 127 | const { target, name, amount, note } = grant.value; |
| 128 | const result = await identity.grantInvites(target, name, amount, note, staff.email); |
| 129 | if (!result.ok) return data({ error: result.error.message, section: "grant" }, { status: 422 }); |
| 130 | throw redirect(`${invitesHref("tree", { name, kind: target })}&done=granted`); |
| 131 | } |
| 132 | case "shared-create": { |
| 133 | const link = parseSharedInvite(form); |
| 134 | if (!link.ok) return data({ error: link.error, section: "shared" }, { status: 422 }); |
| 135 | // Identity records it in the audit log, naming the staff member. |
| 136 | const result = await identity.createSharedInvite(link.value, staff.email); |
| 137 | if (!result.ok) return data({ error: result.error.message, section: "shared" }, { status: 422 }); |
| 138 | throw redirect(`${invitesHref("shared")}&done=shared-created#${result.value.id}`); |
| 139 | } |
| 140 | case "shared-revoke": { |
| 141 | const id = text(form, "id"); |
| 142 | const result = await identity.revokeSharedInvite(id, staff.email); |
| 143 | if (!result.ok) return data({ error: result.error.message, id }, { status: 422 }); |
| 144 | throw back("shared-revoked"); |
| 145 | } |
| 146 | case "mint": { |
| 147 | const email = parseMintEmail(text(form, "email")); |
| 148 | if (!email.ok) return data({ error: email.error, section: "mint" }, { status: 422 }); |
| 149 | const result = await identity.mintInvite(email.value, staff.email); |
| 150 | if (!result.ok) return data({ error: result.error.message, section: "mint" }, { status: 422 }); |
| 151 | // Shown once, here: staff cannot see it again. |
| 152 | return { minted: result.value }; |
| 153 | } |
| 154 | } |
| 155 | return data({ error: "Unknown action." }, { status: 400 }); |
| 156 | } |
| 157 | |
| 158 | type ActionData = Route.ComponentProps["actionData"]; |
| 159 | |
| 160 | function errorFor(actionData: ActionData, key: { id?: string; section?: string }): string | null { |
| 161 | if (!actionData || !("error" in actionData)) return null; |
| 162 | if (key.id && "id" in actionData && actionData.id === key.id) return actionData.error; |
| 163 | if (key.section && "section" in actionData && actionData.section === key.section) return actionData.error; |
| 164 | return null; |
| 165 | } |
| 166 | |
| 167 | function statusBadge(invite: Invite) { |
| 168 | switch (invite.status) { |
| 169 | case "pending": |
| 170 | return <Badge tone="lavender">Pending</Badge>; |
| 171 | case "awaiting_confirmation": |
| 172 | return <Badge tone="lavender">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}, email not confirmed</Badge>; |
| 173 | case "redeemed": |
| 174 | return <Badge tone="mint">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}</Badge>; |
| 175 | case "expired": |
| 176 | return <Badge>Expired</Badge>; |
| 177 | case "revoked": |
| 178 | return <Badge tone="danger">Revoked</Badge>; |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | function SearchForm({ tab, query, placeholder, extra }: { tab: InviteTab; query: string; placeholder: string; extra?: React.ReactNode }) { |
| 183 | return ( |
| 184 | <form method="get" className="flex flex-wrap items-end gap-2"> |
| 185 | <input type="hidden" name="tab" value={tab} /> |
| 186 | <div className="min-w-0 grow basis-60"> |
| 187 | <Input name="q" defaultValue={query} placeholder={placeholder} aria-label="Search" /> |
| 188 | </div> |
| 189 | {extra} |
| 190 | <Button type="submit" variant="quiet"> |
| 191 | <Search size={14} /> |
| 192 | Search |
| 193 | </Button> |
| 194 | </form> |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | /** A note for the invite email, folded away until wanted. */ |
| 199 | function NoteField({ form, label }: { form?: string; label: string }) { |
| 200 | return ( |
| 201 | <details className="group/note"> |
| 202 | <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"> |
| 203 | <MessageSquareText size={13} /> |
| 204 | {label} |
| 205 | </summary> |
| 206 | <div className="mt-2"> |
| 207 | <Textarea |
| 208 | name="note" |
| 209 | form={form} |
| 210 | rows={2} |
| 211 | maxLength={MAX_NOTE} |
| 212 | placeholder="Optional. Goes in the invite email as a note from the g1t team." |
| 213 | aria-label="Note for the invite email" |
| 214 | /> |
| 215 | </div> |
| 216 | </details> |
| 217 | ); |
| 218 | } |
| 219 | |
| 220 | /** Who decided, when, what they wrote, and whether the invite was used. */ |
| 221 | function Decision({ entry }: { entry: WaitlistEntry }) { |
| 222 | if (!entry.decidedBy) return null; |
| 223 | return ( |
| 224 | <div className="space-y-1.5"> |
| 225 | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-faint"> |
| 226 | {entry.status === "invited" ? "Approved" : "Dismissed"} by <span className="font-mono text-muted">{entry.decidedBy}</span> |
| 227 | <When at={entry.decidedAt} /> |
| 228 | {entry.status === "invited" && |
| 229 | (entry.joinedAs ? <Badge tone="mint">Joined as @{entry.joinedAs}</Badge> : <Badge>Invite not used yet</Badge>)} |
| 230 | </p> |
| 231 | {entry.note && ( |
| 232 | <p className="text-xs text-muted"> |
| 233 | Note sent: <span className="whitespace-pre-line text-fg-soft">{entry.note}</span> |
| 234 | </p> |
| 235 | )} |
| 236 | </div> |
| 237 | ); |
| 238 | } |
| 239 | |
| 240 | function Waitlist({ |
| 241 | entries, |
| 242 | query, |
| 243 | status, |
| 244 | selectAll, |
| 245 | actionData, |
| 246 | }: { |
| 247 | entries: WaitlistEntry[]; |
| 248 | query: string; |
| 249 | status: string; |
| 250 | selectAll: boolean; |
| 251 | actionData: ActionData; |
| 252 | }) { |
| 253 | const { pathname, search } = useLocation(); |
| 254 | const waiting = entries.filter((entry) => entry.status === "waiting"); |
| 255 | const params = new URLSearchParams(search); |
| 256 | params.set("select", "all"); |
| 257 | const selectHref = `${pathname}?${params}`; |
| 258 | params.delete("select"); |
| 259 | const clearHref = `${pathname}?${params}`; |
| 260 | const bulkError = errorFor(actionData, { section: "bulk" }); |
| 261 | return ( |
| 262 | <div className="space-y-4"> |
| 263 | <SearchForm |
| 264 | tab="waitlist" |
| 265 | query={query} |
| 266 | placeholder="Email or what they said" |
| 267 | extra={ |
| 268 | <div className="w-full sm:w-40"> |
| 269 | <Select name="status" defaultValue={status} aria-label="Status"> |
| 270 | <option value="waiting">Waiting</option> |
| 271 | <option value="invited">Invited</option> |
| 272 | <option value="dismissed">Dismissed</option> |
| 273 | <option value="all">All</option> |
| 274 | </Select> |
| 275 | </div> |
| 276 | } |
| 277 | /> |
| 278 | {waiting.length > 0 && ( |
| 279 | // Ticked rows belong to this form through their `form` attribute: |
| 280 | // sudo runs no script, so choosing several is plain HTML. |
| 281 | <form |
| 282 | id="bulk" |
| 283 | method="post" |
| 284 | action={`${pathname}${search}`} |
| 285 | className="space-y-3 rounded-lg border border-line bg-raised/40 px-4 py-3 sm:px-5" |
| 286 | > |
| 287 | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> |
| 288 | <p className="text-sm"> |
| 289 | <span className="font-medium">{waiting.length} waiting</span> |
| 290 | <span className="text-muted"> · tick some, or </span> |
| 291 | {selectAll ? ( |
| 292 | <a href={clearHref} className="text-accent hover:underline"> |
| 293 | clear |
| 294 | </a> |
| 295 | ) : ( |
| 296 | <a href={selectHref} className="text-accent hover:underline"> |
| 297 | tick all {Math.min(waiting.length, MAX_BULK)} |
| 298 | </a> |
| 299 | )} |
| 300 | </p> |
| 301 | <div className="ml-auto flex flex-wrap gap-2"> |
| 302 | <Button type="submit" name="intent" value="bulk-dismiss" variant="quiet"> |
| 303 | <X size={14} /> |
| 304 | Dismiss ticked |
| 305 | </Button> |
| 306 | <Button type="submit" name="intent" value="bulk-approve" variant="lavender"> |
| 307 | <Check size={14} /> |
| 308 | Approve and invite ticked |
| 309 | </Button> |
| 310 | </div> |
| 311 | </div> |
| 312 | <NoteField form="bulk" label="Add a note to every invite" /> |
| 313 | {bulkError && <Notice tone="error">{bulkError}</Notice>} |
| 314 | </form> |
| 315 | )} |
| 316 | {entries.length === 0 ? ( |
| 317 | <EmptyState title={status === "waiting" ? "Nobody waiting" : "Nothing here"}> |
| 318 | People who ask for access on g1t.sh/register show up here, newest first. |
| 319 | </EmptyState> |
| 320 | ) : ( |
| 321 | <ul className="divide-y divide-line rounded-lg border border-line bg-surface"> |
| 322 | {entries.map((entry, index) => { |
| 323 | const error = errorFor(actionData, { id: entry.id }); |
| 324 | const open = entry.status === "waiting"; |
| 325 | return ( |
| 326 | <li key={entry.id} id={entry.id} className="scroll-mt-20 px-4 py-3 sm:px-5"> |
| 327 | <div className="flex gap-3"> |
| 328 | {open && ( |
| 329 | <input |
| 330 | type="checkbox" |
| 331 | form="bulk" |
| 332 | name="ids" |
| 333 | value={entry.id} |
| 334 | defaultChecked={selectAll && index < MAX_BULK} |
| 335 | aria-label={`Tick ${entry.email}`} |
| 336 | className="mt-1 size-4 shrink-0 accent-[var(--color-accent)]" |
| 337 | /> |
| 338 | )} |
| 339 | <div className="min-w-0 grow space-y-2"> |
| 340 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 341 | <span className="font-mono text-sm break-all">{entry.email}</span> |
| 342 | {entry.status === "invited" && <Badge tone="mint">Invited</Badge>} |
| 343 | {entry.status === "dismissed" && <Badge>Dismissed</Badge>} |
| 344 | <span className="text-xs text-faint"> |
| 345 | asked <When at={entry.createdAt} /> |
| 346 | {entry.updatedAt !== entry.createdAt && ( |
| 347 | <> |
| 348 | , again <When at={entry.updatedAt} /> |
| 349 | </> |
| 350 | )} |
| 351 | </span> |
| 352 | </div> |
| 353 | {entry.about ? ( |
| 354 | <blockquote className="border-l-2 border-line-strong pl-3 text-sm break-words whitespace-pre-line text-fg-soft">{entry.about}</blockquote> |
| 355 | ) : ( |
| 356 | <p className="text-xs text-faint italic">Did not say what they will build.</p> |
| 357 | )} |
| 358 | <Decision entry={entry} /> |
| 359 | {open && ( |
| 360 | <form method="post" action={`${pathname}${search}#${entry.id}`} className="space-y-2"> |
| 361 | <input type="hidden" name="id" value={entry.id} /> |
| 362 | <div className="flex flex-wrap items-start justify-between gap-2"> |
| 363 | <NoteField label="Add a note to the invite" /> |
| 364 | <div className="ml-auto flex gap-2"> |
| 365 | <Button type="submit" name="intent" value="dismiss" variant="quiet"> |
| 366 | <X size={14} /> |
| 367 | Dismiss |
| 368 | </Button> |
| 369 | <Button type="submit" name="intent" value="approve" variant="lavender"> |
| 370 | <Check size={14} /> |
| 371 | Approve and invite |
| 372 | </Button> |
| 373 | </div> |
| 374 | </div> |
| 375 | </form> |
| 376 | )} |
| 377 | {error && <Notice tone="error">{error}</Notice>} |
| 378 | </div> |
| 379 | </div> |
| 380 | </li> |
| 381 | ); |
| 382 | })} |
| 383 | </ul> |
| 384 | )} |
| 385 | </div> |
| 386 | ); |
| 387 | } |
| 388 | |
| 389 | function InviteTable({ invites, actionData, revoke = true }: { invites: Invite[]; actionData: ActionData; revoke?: boolean }) { |
| 390 | const { pathname, search } = useLocation(); |
| 391 | if (invites.length === 0) return <EmptyState title="No invites">Nothing matches.</EmptyState>; |
| 392 | return ( |
| 393 | <div className="overflow-x-auto rounded-lg border border-line bg-surface"> |
| 394 | <table className="w-full min-w-[38rem] text-sm"> |
| 395 | <thead> |
| 396 | <tr className="text-left text-xs text-faint"> |
| 397 | <th className="px-4 py-2 font-medium">Code</th> |
| 398 | <th className="px-4 py-2 font-medium">For</th> |
| 399 | <th className="px-4 py-2 font-medium">From</th> |
| 400 | <th className="px-4 py-2 font-medium">State</th> |
| 401 | <th className="px-4 py-2 font-medium">Made</th> |
| 402 | <th className="px-4 py-2" /> |
| 403 | </tr> |
| 404 | </thead> |
| 405 | <tbody> |
| 406 | {invites.map((invite) => { |
| 407 | const error = errorFor(actionData, { id: invite.id }); |
| 408 | return ( |
| 409 | <tr key={invite.id} id={invite.id} className="scroll-mt-20 border-t border-line align-top"> |
| 410 | <td className="px-4 py-2 font-mono text-xs whitespace-nowrap">{invite.hint}…</td> |
| 411 | <td className="px-4 py-2"> |
| 412 | <span className="break-all">{invite.email ?? <span className="text-faint">anyone</span>}</span> |
| 413 | {invite.workspace && <span className="block text-xs text-faint">joins {invite.workspace}</span>} |
| 414 | </td> |
| 415 | <td className="px-4 py-2"> |
| 416 | {invite.invitedBy ? ( |
| 417 | <Link to={invitesHref("tree", { name: invite.invitedBy })} className="font-mono hover:underline"> |
| 418 | {invite.invitedBy} |
| 419 | </Link> |
| 420 | ) : ( |
| 421 | <span className="text-xs text-muted">staff{invite.staff ? ` · ${invite.staff}` : ""}</span> |
| 422 | )} |
| 423 | <span className="block text-xs text-faint"> |
| 424 | {invite.chargedTo === "workspace" ? "workspace's invites" : invite.chargedTo === "user" ? "own invites" : "free"} |
| 425 | </span> |
| 426 | </td> |
| 427 | <td className="px-4 py-2"> |
| 428 | {statusBadge(invite)} |
| 429 | {error && <p className="mt-1 text-xs text-danger">{error}</p>} |
| 430 | </td> |
| 431 | <td className="px-4 py-2 text-xs whitespace-nowrap text-muted"> |
| 432 | <When at={invite.createdAt} /> |
| 433 | </td> |
| 434 | <td className="px-4 py-2 text-right"> |
| 435 | {revoke && invite.status === "pending" && ( |
| 436 | <form method="post" action={`${pathname}${search}#${invite.id}`}> |
| 437 | <input type="hidden" name="id" value={invite.id} /> |
| 438 | <Button type="submit" name="intent" value="revoke" variant="danger"> |
| 439 | Revoke |
| 440 | </Button> |
| 441 | </form> |
| 442 | )} |
| 443 | </td> |
| 444 | </tr> |
| 445 | ); |
| 446 | })} |
| 447 | </tbody> |
| 448 | </table> |
| 449 | </div> |
| 450 | ); |
| 451 | } |
| 452 | |
| 453 | function GrantAndMint({ actionData }: { actionData: ActionData }) { |
| 454 | const minted = actionData && "minted" in actionData ? actionData.minted : null; |
| 455 | return ( |
| 456 | <div className="grid gap-5 lg:grid-cols-2"> |
| 457 | <Section title="Grant invites" description="More invites for a person, or for a workspace whose owners share them. A negative number takes some back."> |
| 458 | <form method="post" className="space-y-3"> |
| 459 | <input type="hidden" name="intent" value="grant" /> |
| 460 | <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3"> |
| 461 | <Field label="To"> |
| 462 | <Select name="target" defaultValue="user"> |
| 463 | <option value="user">Person</option> |
| 464 | <option value="workspace">Workspace</option> |
| 465 | </Select> |
| 466 | </Field> |
| 467 | <Field label="Username or slug"> |
| 468 | <Input name="name" required maxLength={40} placeholder="ada" /> |
| 469 | </Field> |
| 470 | </div> |
| 471 | <Field label="Invites" hint="Up to 1000 at a time."> |
| 472 | <Input name="amount" required inputMode="numeric" placeholder="10" /> |
| 473 | </Field> |
| 474 | <Field label="Why" hint="Kept with the grant."> |
| 475 | <Textarea name="note" rows={2} maxLength={500} required placeholder="e.g. Launch partner bringing their team." /> |
| 476 | </Field> |
| 477 | {errorFor(actionData, { section: "grant" }) && <Notice tone="error">{errorFor(actionData, { section: "grant" })}</Notice>} |
| 478 | <div className="flex justify-end"> |
| 479 | <Button type="submit" variant="lavender"> |
| 480 | Grant |
| 481 | </Button> |
| 482 | </div> |
| 483 | </form> |
| 484 | </Section> |
| 485 | <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."> |
| 486 | <form method="post" className="space-y-3"> |
| 487 | <input type="hidden" name="intent" value="mint" /> |
| 488 | <Field label="Email (optional)"> |
| 489 | <Input name="email" type="email" maxLength={254} placeholder="Anyone with the code" /> |
| 490 | </Field> |
| 491 | {errorFor(actionData, { section: "mint" }) && <Notice tone="error">{errorFor(actionData, { section: "mint" })}</Notice>} |
| 492 | {minted?.code && ( |
| 493 | <Notice tone="ok"> |
| 494 | <p>{minted.email ? `Sent to ${minted.email}. ` : ""}Copy it now; it is not shown again.</p> |
| 495 | <p className="mt-1 font-mono text-xs break-all text-fg">https://g1t.sh/invite/{minted.code}</p> |
| 496 | </Notice> |
| 497 | )} |
| 498 | <div className="flex justify-end"> |
| 499 | <Button type="submit"> |
| 500 | <Ticket size={14} /> |
| 501 | Mint |
| 502 | </Button> |
| 503 | </div> |
| 504 | </form> |
| 505 | </Section> |
| 506 | </div> |
| 507 | ); |
| 508 | } |
| 509 | |
| 510 | function Nodes({ nodes }: { nodes: InviteTreeNode[] }) { |
| 511 | if (nodes.length === 0) return null; |
| 512 | return ( |
| 513 | <ul className="space-y-1 border-l border-line pl-4"> |
| 514 | {nodes.map((node) => ( |
| 515 | <li key={node.username}> |
| 516 | <Link to={invitesHref("tree", { name: node.username })} className="font-mono text-sm hover:underline"> |
| 517 | {node.username} |
| 518 | </Link>{" "} |
| 519 | <span className="text-xs text-faint"> |
| 520 | joined <When at={node.joinedAt} /> |
| 521 | </span> |
| 522 | <div className="mt-1"> |
| 523 | <Nodes nodes={node.invited} /> |
| 524 | </div> |
| 525 | </li> |
| 526 | ))} |
| 527 | </ul> |
| 528 | ); |
| 529 | } |
| 530 | |
| 531 | function Tree({ tree, name, kind, actionData }: { tree: InviteTree | null; name: string; kind: string; actionData: ActionData }) { |
| 532 | return ( |
| 533 | <div className="space-y-4"> |
| 534 | <form method="get" className="flex flex-wrap items-end gap-2"> |
| 535 | <input type="hidden" name="tab" value="tree" /> |
| 536 | <div className="w-full sm:w-40"> |
| 537 | <Select name="kind" defaultValue={kind} aria-label="Kind"> |
| 538 | <option value="user">Person</option> |
| 539 | <option value="workspace">Workspace</option> |
| 540 | </Select> |
| 541 | </div> |
| 542 | <div className="min-w-0 grow basis-60"> |
| 543 | <Input name="name" defaultValue={name} placeholder="Username or workspace slug" aria-label="Name" /> |
| 544 | </div> |
| 545 | <Button type="submit" variant="quiet"> |
| 546 | Look up |
| 547 | </Button> |
| 548 | </form> |
| 549 | {name && !tree && <EmptyState title={`No ${kind === "workspace" ? "workspace" : "account"} named ${name}`} />} |
| 550 | {tree && ( |
| 551 | <div className="grid gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"> |
| 552 | <Section title={tree.username} description={kind === "workspace" ? "A workspace's shared invites" : "Where they came from, and whom they brought"}> |
| 553 | <dl className="space-y-2 text-sm"> |
| 554 | {kind !== "workspace" && ( |
| 555 | <div> |
| 556 | <dt className="text-xs text-faint">Invited by</dt> |
| 557 | <dd className="font-mono"> |
| 558 | {tree.shared ? ( |
| 559 | <Link to={`${invitesHref("shared")}#${tree.shared.id}`} className="font-sans hover:underline"> |
| 560 | {joinedThrough(tree.shared.label)} |
| 561 | </Link> |
| 562 | ) : tree.invitedBy.length > 0 |
| 563 | ? tree.invitedBy.map((username, index) => ( |
| 564 | <span key={username}> |
| 565 | {index > 0 && <span className="text-faint"> ← </span>} |
| 566 | <Link to={invitesHref("tree", { name: username })} className="hover:underline"> |
| 567 | {username} |
| 568 | </Link> |
| 569 | </span> |
| 570 | )) |
| 571 | : tree.staff |
| 572 | ? `staff (${tree.staff})` |
| 573 | : "nobody: made without an invite"} |
| 574 | </dd> |
| 575 | </div> |
| 576 | )} |
| 577 | <div> |
| 578 | <dt className="text-xs text-faint">Allowance</dt> |
| 579 | <dd> |
| 580 | {tree.allowance.limit == null |
| 581 | ? `No limit · ${tree.allowance.used} out` |
| 582 | : `${tree.allowance.used} of ${tree.allowance.limit} used · ${tree.allowance.remaining} left`} |
| 583 | </dd> |
| 584 | </div> |
| 585 | {tree.grants.length > 0 && ( |
| 586 | <div> |
| 587 | <dt className="text-xs text-faint">Grants</dt> |
| 588 | <dd> |
| 589 | <ul className="space-y-1"> |
| 590 | {tree.grants.map((grant) => ( |
| 591 | <li key={grant.createdAt} className="text-xs"> |
| 592 | <span className="tabular font-medium">{grant.amount > 0 ? `+${grant.amount}` : grant.amount}</span> by{" "} |
| 593 | <span className="font-mono">{grant.grantedBy}</span> <When at={grant.createdAt} /> |
| 594 | {grant.note && <span className="block text-muted">{grant.note}</span>} |
| 595 | </li> |
| 596 | ))} |
| 597 | </ul> |
| 598 | </dd> |
| 599 | </div> |
| 600 | )} |
| 601 | {kind !== "workspace" && ( |
| 602 | <div> |
| 603 | <dt className="mb-1 text-xs text-faint">Invited</dt> |
| 604 | <dd>{tree.invited.length > 0 ? <Nodes nodes={tree.invited} /> : <span className="text-muted">Nobody yet</span>}</dd> |
| 605 | </div> |
| 606 | )} |
| 607 | </dl> |
| 608 | </Section> |
| 609 | <div className="min-w-0"> |
| 610 | <h3 className="mb-2 text-sm font-medium">Invites</h3> |
| 611 | <InviteTable invites={tree.invites} actionData={actionData} /> |
| 612 | </div> |
| 613 | </div> |
| 614 | )} |
| 615 | </div> |
| 616 | ); |
| 617 | } |
| 618 | |
| 619 | /** |
| 620 | * Shared invite links: make one for a group, copy it while it is live, |
| 621 | * revoke it, and see who joined through it. Plain HTML, as all of sudo: |
| 622 | * the link sits in a read-only field to select and copy. |
| 623 | */ |
| 624 | function SharedLinks({ |
| 625 | links, |
| 626 | expiry, |
| 627 | actionData, |
| 628 | }: { |
| 629 | links: SharedInvite[]; |
| 630 | expiry: { default: string; min: string; max: string }; |
| 631 | actionData: ActionData; |
| 632 | }) { |
| 633 | const { pathname, search } = useLocation(); |
| 634 | const createError = errorFor(actionData, { section: "shared" }); |
| 635 | return ( |
| 636 | <div className="grid gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"> |
| 637 | <Section |
| 638 | title="Make a shared link" |
| 639 | description="One link for a group, such as a conference's judges or a community. Each use makes a new account, which makes its own workspace; the link never joins anyone to an existing one, and uses nobody's invites." |
| 640 | > |
| 641 | <form method="post" action={`${pathname}${search}`} className="space-y-3"> |
| 642 | <input type="hidden" name="intent" value="shared-create" /> |
| 643 | <Field label="Label" hint="Not secret: the sign-up page says “Invited as part of …”."> |
| 644 | <Input name="label" required maxLength={MAX_SHARED_LABEL} placeholder="Cloudflare judges" /> |
| 645 | </Field> |
| 646 | <div className="grid grid-cols-2 gap-3"> |
| 647 | <Field label="Accounts it makes" hint={`1 to ${MAX_SHARED_USES}.`}> |
| 648 | <Input name="max_uses" type="number" required min={1} max={MAX_SHARED_USES} step={1} defaultValue={25} inputMode="numeric" /> |
| 649 | </Field> |
| 650 | <Field label="Last day" hint="Works until the end of it (UTC)."> |
| 651 | <Input name="expires_on" type="date" required defaultValue={expiry.default} min={expiry.min} max={expiry.max} /> |
| 652 | </Field> |
| 653 | </div> |
| 654 | <Field label="Email domains (optional)" hint={`Only addresses at these, up to ${MAX_SHARED_DOMAINS}. Empty for any address.`}> |
| 655 | <Input name="domains" maxLength={600} placeholder="cloudflare.com, example.org" spellCheck={false} autoCapitalize="none" /> |
| 656 | </Field> |
| 657 | {createError && <Notice tone="error">{createError}</Notice>} |
| 658 | <div className="flex justify-end"> |
| 659 | <Button type="submit" variant="lavender"> |
| 660 | <Link2 size={14} /> |
| 661 | Make link |
| 662 | </Button> |
| 663 | </div> |
| 664 | </form> |
| 665 | </Section> |
| 666 | <div className="min-w-0 space-y-3"> |
| 667 | <h3 className="text-sm font-medium">Shared links</h3> |
| 668 | {links.length === 0 ? ( |
| 669 | <EmptyState title="No shared links">Links you make appear here, newest first, with everyone who joined through each.</EmptyState> |
| 670 | ) : ( |
| 671 | <ul className="space-y-3"> |
| 672 | {links.map((link) => { |
| 673 | const state = sharedStatus(link.status); |
| 674 | const error = errorFor(actionData, { id: link.id }); |
| 675 | return ( |
| 676 | <li key={link.id} id={link.id} className="scroll-mt-20 space-y-3 rounded-lg border border-line bg-surface px-4 py-3 sm:px-5"> |
| 677 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 678 | <span className="font-medium break-words">{link.label}</span> |
| 679 | <Badge tone={state.tone}>{state.label}</Badge> |
| 680 | <span className="text-xs text-muted tabular">{usesLine(link)}</span> |
| 681 | </div> |
| 682 | <dl className="grid gap-x-4 gap-y-1 text-xs sm:grid-cols-2"> |
| 683 | <div> |
| 684 | <dt className="inline text-faint">Code </dt> |
| 685 | <dd className="inline font-mono">{link.hint}…</dd> |
| 686 | </div> |
| 687 | <div> |
| 688 | <dt className="inline text-faint">Expires </dt> |
| 689 | <dd className="inline"> |
| 690 | <When at={link.expiresAt} /> |
| 691 | </dd> |
| 692 | </div> |
| 693 | <div className="sm:col-span-2">{domainsLine(link.domains)}</div> |
| 694 | <div className="sm:col-span-2 text-faint"> |
| 695 | Made by <span className="font-mono text-muted">{link.staff}</span> <When at={link.createdAt} /> |
| 696 | {link.revokedAt && ( |
| 697 | <> |
| 698 | {" "} |
| 699 | · revoked by <span className="font-mono text-muted">{link.revokedBy ?? "staff"}</span> <When at={link.revokedAt} /> |
| 700 | </> |
| 701 | )} |
| 702 | </div> |
| 703 | </dl> |
| 704 | {link.status === "live" && |
| 705 | (link.code ? ( |
| 706 | <Field label="Link (select it to copy)"> |
| 707 | <Input readOnly value={sharedInviteLink(link.code)} className="font-mono text-xs" aria-label={`Shared link for ${link.label}`} /> |
| 708 | </Field> |
| 709 | ) : ( |
| 710 | <p className="text-xs text-faint">The link cannot be shown again here: identity keeps no copy without IDENTITY_KEY.</p> |
| 711 | ))} |
| 712 | {link.accounts.length > 0 ? ( |
| 713 | <details> |
| 714 | <summary className="cursor-pointer text-xs text-muted select-none hover:text-fg"> |
| 715 | {link.accounts.length} joined through it |
| 716 | </summary> |
| 717 | <ul className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs"> |
| 718 | {link.accounts.map((account, index) => ( |
| 719 | <li key={`${account.username ?? "purged"}-${index}`}> |
| 720 | {account.username ? ( |
| 721 | <Link to={`/users/${encodeURIComponent(account.username)}`} className="font-mono hover:underline"> |
| 722 | {account.username} |
| 723 | </Link> |
| 724 | ) : ( |
| 725 | <span className="text-faint">a purged account</span> |
| 726 | )}{" "} |
| 727 | <span className="text-faint"> |
| 728 | <When at={account.joinedAt} /> |
| 729 | </span> |
| 730 | </li> |
| 731 | ))} |
| 732 | </ul> |
| 733 | </details> |
| 734 | ) : ( |
| 735 | <p className="text-xs text-faint">Nobody has joined through it yet.</p> |
| 736 | )} |
| 737 | {link.status !== "revoked" && ( |
| 738 | <form method="post" action={`${pathname}${search}#${link.id}`} className="flex justify-end"> |
| 739 | <input type="hidden" name="id" value={link.id} /> |
| 740 | <Button type="submit" name="intent" value="shared-revoke" variant="danger"> |
| 741 | Revoke |
| 742 | </Button> |
| 743 | </form> |
| 744 | )} |
| 745 | {error && <Notice tone="error">{error}</Notice>} |
| 746 | </li> |
| 747 | ); |
| 748 | })} |
| 749 | </ul> |
| 750 | )} |
| 751 | </div> |
| 752 | </div> |
| 753 | ); |
| 754 | } |
| 755 | |
| 756 | export default function Invites({ loaderData, actionData }: Route.ComponentProps) { |
| 757 | const { tab, query, status, waitlist, invites, tree, name, kind, error, done, selectAll, shared, expiry } = loaderData; |
| 758 | return ( |
| 759 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> |
| 760 | <PageHeader |
| 761 | title="Invites" |
| 762 | description="g1t.sh is invite-only. Approve people from the waitlist, find and revoke invites, hand a group one shared link, grant more to a person or a workspace, and trace who brought whom." |
| 763 | /> |
| 764 | <nav aria-label="Invites" className="mt-5 flex flex-wrap gap-2"> |
| 765 | {INVITE_TABS.map((value) => ( |
| 766 | <Link |
| 767 | key={value} |
| 768 | to={invitesHref(value)} |
| 769 | aria-current={value === tab ? "page" : undefined} |
| 770 | className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${ |
| 771 | value === tab ? "border-accent/50 bg-accent/10 text-accent" : "border-line text-muted hover:border-line-strong hover:text-fg" |
| 772 | }`} |
| 773 | > |
| 774 | {TAB_LABEL[value]} |
| 775 | </Link> |
| 776 | ))} |
| 777 | </nav> |
| 778 | <div className="mt-5 space-y-3 empty:hidden"> |
| 779 | {done && <Notice tone="ok">{done}</Notice>} |
| 780 | {error && <Notice tone="warn">Identity did not answer: {error}</Notice>} |
| 781 | </div> |
| 782 | <div className="mt-5"> |
| 783 | {tab === "waitlist" && ( |
| 784 | <Waitlist entries={waitlist} query={query} status={status} selectAll={selectAll} actionData={actionData} /> |
| 785 | )} |
| 786 | {tab === "invites" && ( |
| 787 | <div className="space-y-4"> |
| 788 | <SearchForm tab="invites" query={query} placeholder="A code's start (g1t-k7m2), an email, or a username" /> |
| 789 | <InviteTable invites={invites} actionData={actionData} /> |
| 790 | </div> |
| 791 | )} |
| 792 | {tab === "shared" && <SharedLinks links={shared} expiry={expiry} actionData={actionData} />} |
| 793 | {tab === "grant" && <GrantAndMint actionData={actionData} />} |
| 794 | {tab === "tree" && <Tree tree={tree} name={name} kind={kind} actionData={actionData} />} |
| 795 | </div> |
| 796 | </main> |
| 797 | ); |
| 798 | } |