Skip to content
798 linesCodeBlameRaw
1import { Check, Link2, MessageSquareText, Search, Ticket, X } from "lucide-react";
2import { Link, data, redirect, useLocation } from "react-router";
3
4import type { Invite, InviteTree, InviteTreeNode, SharedInvite, WaitlistEntry } from "@g1t/contracts";
5
6import type { Route } from "./+types/invites";
7import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Select, Textarea, When } from "~/components/ui";
8import { text } from "~/lib/forms";
9import {
10 INVITE_TABS,
11 type InviteTab,
12 MAX_BULK,
13 MAX_NOTE,
14 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";
36import { identity } from "~/lib/services.server";
37import { settle } from "~/lib/settle";
38import { requireStaff } from "~/lib/staff";
39
40export const meta: Route.MetaFunction = () => [{ title: "Invites · sudo" }, { name: "robots", content: "noindex, nofollow" }];
41
42export 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
77export 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
158type ActionData = Route.ComponentProps["actionData"];
159
160function 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
167function 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
182function 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. */
199function 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. */
221function 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
240function 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
389function 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
453function 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
510function 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
531function 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 */
624function 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
756export 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}