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
148 linesCodeBlameRaw
1import { Search } from "lucide-react";
2import { data, redirect, useLocation } from "react-router";
3
4import type { Invite } from "@g1t/contracts";
5
6import type { Route } from "./+types/invites";
7import { Badge, Button, EmptyState, Input, Notice, PageHeader, When } from "~/components/ui";
8import { text } from "~/lib/forms";
9import { doneMessage } 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 invites = await settle(identity.invites(query || null));
21 return {
22 query,
23 invites: invites.ok ? invites.value : [],
24 error: invites.ok ? null : invites.error,
25 done: doneMessage(url.searchParams.get("done")),
26 };
27}
28
29export async function action({ request, context }: Route.ActionArgs) {
30 const staff = requireStaff(context);
31 const form = await request.formData();
32 if (text(form, "intent") !== "revoke") return data({ error: "Unknown action.", id: "" }, { status: 400 });
33 const result = await identity.revokeInvite(text(form, "id"), staff.email);
34 if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 });
35 const url = new URL(request.url);
36 url.searchParams.set("done", "revoked");
37 throw redirect(`${url.pathname}${url.search}`);
38}
39
40type ActionData = Route.ComponentProps["actionData"];
41
42function statusBadge(invite: Invite) {
43 switch (invite.status) {
44 case "pending":
45 return <Badge tone="lavender">Pending</Badge>;
46 case "awaiting_confirmation":
47 return <Badge tone="lavender">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}, email not confirmed</Badge>;
48 case "awaiting_answer":
49 return <Badge tone="lavender">Waiting for an answer</Badge>;
50 case "redeemed":
51 return <Badge tone="mint">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}</Badge>;
52 case "declined":
53 return <Badge>Declined</Badge>;
54 case "expired":
55 return <Badge>Expired</Badge>;
56 case "revoked":
57 return <Badge tone="danger">Revoked</Badge>;
58 }
59}
60
61function InviteTable({ invites, actionData }: { invites: Invite[]; actionData: ActionData }) {
62 const { pathname, search } = useLocation();
63 if (invites.length === 0) return <EmptyState title="No invites">Nothing matches.</EmptyState>;
64 return (
65 <div className="overflow-x-auto rounded-lg border border-line bg-surface">
66 <table className="w-full min-w-[38rem] text-sm">
67 <thead>
68 <tr className="text-left text-xs text-faint">
69 <th className="px-4 py-2 font-medium">Code</th>
70 <th className="px-4 py-2 font-medium">For</th>
71 <th className="px-4 py-2 font-medium">From</th>
72 <th className="px-4 py-2 font-medium">State</th>
73 <th className="px-4 py-2 font-medium">Made</th>
74 <th className="px-4 py-2" />
75 </tr>
76 </thead>
77 <tbody>
78 {invites.map((invite) => {
79 const error = actionData && "id" in actionData && actionData.id === invite.id ? actionData.error : null;
80 return (
81 <tr key={invite.id} id={invite.id} className="scroll-mt-20 border-t border-line align-top">
82 <td className="px-4 py-2 font-mono text-xs whitespace-nowrap">{invite.hint}…</td>
83 <td className="px-4 py-2">
84 <span className="break-all">
85 {invite.email ?? (invite.invitee ? `@${invite.invitee}` : <span className="text-faint">anyone</span>)}
86 </span>
87 {invite.workspace && <span className="block text-xs text-faint">joins {invite.workspace}</span>}
88 </td>
89 <td className="px-4 py-2">
90 {invite.invitedBy ? (
91 <span className="font-mono">{invite.invitedBy}</span>
92 ) : (
93 <span className="text-xs text-muted">staff{invite.staff ? ` · ${invite.staff}` : ""}</span>
94 )}
95 </td>
96 <td className="px-4 py-2">
97 {statusBadge(invite)}
98 {error && <p className="mt-1 text-xs text-danger">{error}</p>}
99 </td>
100 <td className="px-4 py-2 text-xs whitespace-nowrap text-muted">
101 <When at={invite.createdAt} />
102 </td>
103 <td className="px-4 py-2 text-right">
104 {invite.status === "pending" && (
105 <form method="post" action={`${pathname}${search}#${invite.id}`}>
106 <input type="hidden" name="id" value={invite.id} />
107 <Button type="submit" name="intent" value="revoke" variant="danger">
108 Revoke
109 </Button>
110 </form>
111 )}
112 </td>
113 </tr>
114 );
115 })}
116 </tbody>
117 </table>
118 </div>
119 );
120}
121
122export default function Invites({ loaderData, actionData }: Route.ComponentProps) {
123 const { query, invites, error, done } = loaderData;
124 return (
125 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
126 <PageHeader
127 title="Invites"
128 description="Every invitation into a workspace or onto a repository sent to an address, newest first. Anyone may register; find an invitation here to revoke it, for instance one sent to an address that did not ask for it."
129 />
130 <div className="mt-5 space-y-3 empty:hidden">
131 {done && <Notice tone="ok">{done}</Notice>}
132 {error && <Notice tone="warn">Identity did not answer: {error}</Notice>}
133 </div>
134 <div className="mt-5 space-y-4">
135 <form method="get" className="flex flex-wrap items-end gap-2">
136 <div className="min-w-0 grow basis-60">
137 <Input name="q" defaultValue={query} placeholder="A code's start (g1t-k7m2), an email, or a username" aria-label="Search" />
138 </div>
139 <Button type="submit" variant="quiet">
140 <Search size={14} />
141 Search
142 </Button>
143 </form>
144 <InviteTable invites={invites} actionData={actionData} />
145 </div>
146 </main>
147 );
148}