g1t/apps/sudo/app/routes/invites.tsx

623 lines27,214 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1import { Check, MessageSquareText, Search, Ticket, X } from "lucide-react";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2import { Link, data, redirect, useLocation } from "react-router";
3
4import type { Invite, InviteTree, InviteTreeNode, 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,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas12 MAX_BULK,
13 MAX_NOTE,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look14 TAB_LABEL,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas15 doneMessage,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look16 invitesHref,
17 parseGrant,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas18 parseIds,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look19 parseMintEmail,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas20 parseNote,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look21 parseTab,
22 parseWaitlistStatus,
23} from "~/lib/invites";
24import { identity } from "~/lib/services.server";
25import { settle } from "~/lib/settle";
26import { requireStaff } from "~/lib/staff";
27
28export const meta: Route.MetaFunction = () => [{ title: "Invites · sudo" }, { name: "robots", content: "noindex, nofollow" }];
29
30export 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");
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas39 const selectAll = url.searchParams.get("select") === "all";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look40 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,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas55 done: doneMessage(done, url.searchParams.get("n")),
56 selectAll,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look57 };
58}
59
60export async function action({ request, context }: Route.ActionArgs) {
61 const staff = requireStaff(context);
62 const form = await request.formData();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas63 const back = (done: string, n?: number) => {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look64 const url = new URL(request.url);
65 url.searchParams.set("done", done);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas66 url.searchParams.delete("select");
67 if (n) url.searchParams.set("n", String(n));
68 else url.searchParams.delete("n");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look69 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";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas75 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);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look78 if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
79 throw back(approve ? "approved" : "dismissed");
80 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas81 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look102 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
127type ActionData = Route.ComponentProps["actionData"];
128
129function 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
136function 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
149function 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
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas165/** A note for the invite email, folded away until wanted. */
166function 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. */
188function 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
207function 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}) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look220 const { pathname, search } = useLocation();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas221 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" });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look228 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 />
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas245 {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 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look283 {entries.length === 0 ? (
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas284 <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>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look287 ) : (
288 <ul className="divide-y divide-line rounded-lg border border-line bg-surface">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas289 {entries.map((entry, index) => {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look290 const error = errorFor(actionData, { id: entry.id });
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas291 const open = entry.status === "waiting";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look292 return (
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas293 <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>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look343 )}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas344 {error && <Notice tone="error">{error}</Notice>}
345 </div>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look346 </div>
347 </li>
348 );
349 })}
350 </ul>
351 )}
352 </div>
353 );
354}
355
356function 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
420function 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
477function 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
498function 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
582export default function Invites({ loaderData, actionData }: Route.ComponentProps) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas583 const { tab, query, status, waitlist, invites, tree, name, kind, error, done, selectAll } = loaderData;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look584 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">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas609 {tab === "waitlist" && (
610 <Waitlist entries={waitlist} query={query} status={status} selectAll={selectAll} actionData={actionData} />
611 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look612 {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}