Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

233 linesCodeBlameRaw
1import { Search } from "lucide-react";
2import { Link, data, redirect, useLocation } from "react-router";
3
4import type { Invite } from "@g1t/contracts";
5
6import type { Route } from "./+types/invites";
7import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Textarea, When } from "~/components/ui";
8import { text } from "~/lib/forms";
9import { type InvitesTab, doneMessage, invitesHref, invitesTab, parseGrant } from "~/lib/invites";
10import { identity } from "~/lib/services.server";
11import { settle } from "~/lib/settle";
12import { requireStaff } from "~/lib/staff";
13
14export const meta: Route.MetaFunction = () => [{ title: "Invites · sudo" }, { name: "robots", content: "noindex, nofollow" }];
15
16export async function loader({ request, context }: Route.LoaderArgs) {
17 requireStaff(context);
18 const url = new URL(request.url);
19 const query = url.searchParams.get("q")?.trim() ?? "";
20 const tab = invitesTab(url.searchParams.get("tab"));
21 const invites = await settle(identity.invites(query || null, { toG1t: tab === "g1t" }));
22 const granted = url.searchParams.get("left");
23 return {
24 query,
25 tab,
26 invites: invites.ok ? invites.value : [],
27 error: invites.ok ? null : invites.error,
28 done: doneMessage(url.searchParams.get("done")),
29 granted: granted && url.searchParams.get("who") ? { who: url.searchParams.get("who")!, left: granted } : null,
30 };
31}
32
33export async function action({ request, context }: Route.ActionArgs) {
34 const staff = requireStaff(context);
35 const form = await request.formData();
36 const url = new URL(request.url);
37 switch (text(form, "intent")) {
38 case "revoke": {
39 const result = await identity.revokeInvite(text(form, "id"), staff.email);
40 if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
41 url.searchParams.set("done", "revoked");
42 throw redirect(`${url.pathname}${url.search}`);
43 }
44 case "grant": {
45 const grant = parseGrant(form);
46 if (!grant.ok) return data({ error: grant.error, id: "grant" }, { status: 422 });
47 const { username, amount, note } = grant.value;
48 const result = await identity.grantInvites(username, amount, note, staff.email);
49 if (!result.ok) return data({ error: result.error.message, id: "grant" }, { status: 422 });
50 throw redirect(invitesHref({ tab: "g1t", q: username, done: "granted", who: username, left: `${result.value.remaining} of ${result.value.limit}` }));
51 }
52 }
53 return data({ error: "Unknown action.", id: "" }, { status: 400 });
54}
55
56type ActionData = Route.ComponentProps["actionData"];
57
58function statusBadge(invite: Invite) {
59 switch (invite.status) {
60 case "pending":
61 return <Badge tone="lavender">Pending</Badge>;
62 case "awaiting_confirmation":
63 return <Badge tone="lavender">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}, email not confirmed</Badge>;
64 case "awaiting_answer":
65 return <Badge tone="lavender">Waiting for an answer</Badge>;
66 case "redeemed":
67 return (
68 <Badge tone="mint">
69 {invite.chargedTo === "user" ? "Joined" : "Used"}
70 {invite.redeemedBy ? ` as ${invite.redeemedBy}` : ""}
71 </Badge>
72 );
73 case "declined":
74 return <Badge>Declined</Badge>;
75 case "expired":
76 return <Badge>Expired</Badge>;
77 case "revoked":
78 return <Badge tone="danger">Revoked</Badge>;
79 }
80}
81
82/** What an invite is: an invite to g1t from Invite a friend, or an invitation into a workspace or onto a repository. */
83function kindOf(invite: Invite): string {
84 if (invite.chargedTo === "user") return invite.workspace ? `To g1t and ${invite.workspace}` : "To g1t";
85 return invite.workspace ? `Joins ${invite.workspace}` : "Repository or staff";
86}
87
88function InviteTable({ invites, actionData }: { invites: Invite[]; actionData: ActionData }) {
89 const { pathname, search } = useLocation();
90 if (invites.length === 0) return <EmptyState title="No invites">Nothing matches.</EmptyState>;
91 return (
92 <div className="overflow-x-auto rounded-lg border border-line bg-surface">
93 <table className="w-full min-w-152 text-sm">
94 <thead>
95 <tr className="text-left text-xs text-faint">
96 <th className="px-4 py-2 font-medium">Code</th>
97 <th className="px-4 py-2 font-medium">For</th>
98 <th className="px-4 py-2 font-medium">From</th>
99 <th className="px-4 py-2 font-medium">State</th>
100 <th className="px-4 py-2 font-medium">Made</th>
101 <th className="px-4 py-2" />
102 </tr>
103 </thead>
104 <tbody>
105 {invites.map((invite) => {
106 const error = actionData && "id" in actionData && actionData.id === invite.id ? actionData.error : null;
107 return (
108 <tr key={invite.id} id={invite.id} className="scroll-mt-20 border-t border-line align-top">
109 <td className="px-4 py-2 font-mono text-xs whitespace-nowrap">{invite.hint}…</td>
110 <td className="px-4 py-2">
111 <span className="break-all">{invite.email ?? (invite.invitee ? `@${invite.invitee}` : <span className="text-faint">anyone</span>)}</span>
112 <span className="block text-xs text-faint">{kindOf(invite)}</span>
113 </td>
114 <td className="px-4 py-2">
115 {invite.invitedBy ? (
116 <Link to={`/users/${invite.invitedBy}`} className="font-mono hover:text-accent">
117 {invite.invitedBy}
118 </Link>
119 ) : (
120 <span className="text-xs text-muted">staff{invite.staff ? ` · ${invite.staff}` : ""}</span>
121 )}
122 </td>
123 <td className="px-4 py-2">
124 {statusBadge(invite)}
125 {error && <p className="mt-1 text-xs text-danger">{error}</p>}
126 </td>
127 <td className="px-4 py-2 text-xs whitespace-nowrap text-muted">
128 <When at={invite.createdAt} />
129 </td>
130 <td className="px-4 py-2 text-right">
131 {invite.status === "pending" && (
132 <form method="post" action={`${pathname}${search}#${invite.id}`}>
133 <input type="hidden" name="id" value={invite.id} />
134 <Button type="submit" name="intent" value="revoke" variant="danger">
135 Revoke
136 </Button>
137 </form>
138 )}
139 </td>
140 </tr>
141 );
142 })}
143 </tbody>
144 </table>
145 </div>
146 );
147}
148
149/** More invites to g1t for one person, or fewer. */
150function Grant({ actionData }: { actionData: ActionData }) {
151 const error = actionData && "id" in actionData && actionData.id === "grant" ? actionData.error : null;
152 return (
153 <Section
154 title="Grant invites to g1t"
155 description="Everyone has 20. Give a person more, or take some back with a negative number; kept with why and who granted them."
156 >
157 <form method="post" className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_8rem]">
158 <input type="hidden" name="intent" value="grant" />
159 <Field label="Username">
160 <Input name="username" required maxLength={40} placeholder="ada" />
161 </Field>
162 <Field label="Invites" hint="Up to 1000.">
163 <Input name="amount" required inputMode="numeric" placeholder="10" />
164 </Field>
165 <Field label="Why" className="sm:col-span-2">
166 <Textarea name="note" rows={2} maxLength={500} required placeholder="e.g. Bringing her whole meetup." />
167 </Field>
168 {error && (
169 <div className="sm:col-span-2">
170 <Notice tone="error">{error}</Notice>
171 </div>
172 )}
173 <div className="flex justify-end sm:col-span-2">
174 <Button type="submit" variant="lavender">
175 Grant
176 </Button>
177 </div>
178 </form>
179 </Section>
180 );
181}
182
183const TABS: { value: InvitesTab; label: string }[] = [
184 { value: "all", label: "Every invite" },
185 { value: "g1t", label: "Invites to g1t" },
186];
187
188export default function Invites({ loaderData, actionData }: Route.ComponentProps) {
189 const { query, tab, invites, error, done, granted } = loaderData;
190 return (
191 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
192 <PageHeader
193 title="Invites"
194 description="Every invite, newest first: invitations into a workspace or onto a repository, and invites to g1t that people send their friends with Invite a friend. Anyone may register; an invite is never needed. Find one here to revoke it, for instance one sent to an address that did not ask for it."
195 />
196 <div className="mt-5 space-y-3 empty:hidden">
197 {done && <Notice tone="ok">{granted ? `${done} @${granted.who} has ${granted.left} invites left.` : done}</Notice>}
198 {error && <Notice tone="warn">Identity did not answer: {error}</Notice>}
199 </div>
200 <nav aria-label="Which invites" className="mt-6 flex gap-1 overflow-x-auto border-b border-line">
201 {TABS.map((entry) => {
202 const current = tab === entry.value;
203 return (
204 <Link
205 key={entry.value}
206 to={invitesHref({ tab: entry.value === "all" ? null : entry.value, q: query })}
207 aria-current={current ? "page" : undefined}
208 className={`-mb-px flex shrink-0 items-center gap-2 border-b-2 px-3 py-2 text-sm whitespace-nowrap ${
209 current ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg"
210 }`}
211 >
212 {entry.label}
213 </Link>
214 );
215 })}
216 </nav>
217 <div className="mt-4 space-y-4">
218 <form method="get" className="flex flex-wrap items-end gap-2">
219 {tab !== "all" && <input type="hidden" name="tab" value={tab} />}
220 <div className="min-w-0 grow basis-60">
221 <Input name="q" defaultValue={query} placeholder="A code's start (g1t-k7m2), an email, or a username" aria-label="Search" />
222 </div>
223 <Button type="submit" variant="quiet">
224 <Search size={14} />
225 Search
226 </Button>
227 </form>
228 <InviteTable invites={invites} actionData={actionData} />
229 {tab === "g1t" && <Grant actionData={actionData} />}
230 </div>
231 </main>
232 );
233}