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.

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