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
267 linesCodeBlameRaw
1import { Check, Copy, Ellipsis, Mail, RotateCw, X } from "lucide-react";
2import { useEffect, useRef, useState } from "react";
3import { Link, data, useFetcher, useSearchParams } from "react-router";
4
5import type { Invite } from "@g1t/contracts";
6
7import type { Route } from "./+types/member-invitations";
8import { page } from "../../lib/meta";
9import { EmptyState, ErrorText, TimeAgo } from "../../components/ui";
10import { Badge } from "../../components/ui/badge";
11import { Button } from "../../components/ui/button";
12import { Card } from "../../components/ui/card";
13import {
14 DropdownMenu,
15 DropdownMenuContent,
16 DropdownMenuItem,
17 DropdownMenuSeparator,
18 DropdownMenuTrigger,
19} from "../../components/ui/dropdown-menu";
20import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table";
21import { ToggleGroup, ToggleGroupItem } from "../../components/ui/toggle-group";
22import { InviteDialog } from "../../components/invite-dialog";
23import { StartPlanToInvite } from "../../components/start-plan";
24import { UserCard } from "../../components/user-card";
25import { invitationFromForm, inviteFromForm } from "../../lib/member-invitations.server";
26import {
27 INVITATION_FILTERS,
28 type InvitationResult,
29 filterInvitations,
30 invitationCounts,
31 invitationFilter,
32 invitationFilterOf,
33 invitationTone,
34 inviteLink,
35 inviteState,
36} from "../../lib/invites";
37import { billing, identity } from "../../lib/services.server";
38import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
39
40export function meta({ params, ...args }: Route.MetaArgs) {
41 return page(args, { title: `Invitations · ${params.owner} · g1t` });
42}
43
44/**
45 * Invitations: every invitation to join the workspace, newest first, with
46 * where each stands, and the way to send one again, revoke it or copy its
47 * link. Owners only; to anyone else the page does not exist.
48 */
49export async function loader({ params, context, request }: Route.LoaderArgs) {
50 const viewer = getViewer(context);
51 if (roleIn(viewer, params.owner) !== "owner") throw data(null, { status: 404 });
52 const [invites, workspace, free] = await Promise.all([
53 identity.workspaceInvites(params.owner, viewer),
54 identity.getWorkspace(params.owner),
55 billing.freeWorkspaces([params.owner]).catch(() => [] as string[]),
56 ]);
57 return {
58 name: workspace?.name?.trim() || params.owner,
59 free: free.includes(params.owner.toLowerCase()),
60 invites: invites.ok ? invites.value : [],
61 origin: new URL(request.url).origin,
62 };
63}
64
65export async function action({ request, params, context }: Route.ActionArgs) {
66 assertSameOrigin(request);
67 const user = requireUser(context, request);
68 const form = await request.formData();
69 const changed = await invitationFromForm(user, params.owner, form);
70 if (changed) return changed;
71 return inviteFromForm(user, params.owner, form);
72}
73
74/** The role an invitation joins with; member when it names none. */
75function roleOf(invite: Invite): string {
76 return invite.role === "owner" ? "Owner" : "Member";
77}
78
79/** The person or address an invitation is for. */
80function Who({ invite }: { invite: Invite }) {
81 if (invite.invitee && !invite.email) {
82 return (
83 <UserCard username={invite.invitee}>
84 <Link to={`/u/${invite.invitee}`} className="font-mono hover:text-accent">
85 @{invite.invitee}
86 </Link>
87 </UserCard>
88 );
89 }
90 if (invite.email) {
91 return (
92 <span className="inline-flex min-w-0 items-center gap-1.5">
93 <Mail size={13} aria-hidden="true" className="shrink-0 text-faint" />
94 <span className="truncate">{invite.email}</span>
95 </span>
96 );
97 }
98 return <span className="text-muted">Anyone with the link</span>;
99}
100
101/** The ⋯ menu on one invitation: copy its link, send it again, revoke it. */
102function InvitationMenu({ invite, origin, onDone }: { invite: Invite; origin: string; onDone: (result: InvitationResult) => void }) {
103 const fetcher = useFetcher<InvitationResult>();
104 const [copied, setCopied] = useState(false);
105 const pending = invitationFilterOf(invite.status) === "pending";
106 const resendable = invite.status === "pending";
107 const link = invite.code ? inviteLink(invite.code, origin) : null;
108 const working = fetcher.state !== "idle";
109 // What the service answered, once, to the page.
110 const answered = fetcher.state === "idle" ? fetcher.data : undefined;
111 const told = useRef<InvitationResult | undefined>(undefined);
112 useEffect(() => {
113 if (!answered || told.current === answered) return;
114 told.current = answered;
115 onDone(answered);
116 }, [answered, onDone]);
117 if (!pending && !link) return null;
118 return (
119 <DropdownMenu>
120 <DropdownMenuTrigger asChild>
121 <Button type="button" aria-label={`Manage the invitation for ${invite.email ?? invite.invitee ?? invite.hint}`} variant="ghost" size="inline" className="p-1.5 text-faint max-sm:p-2.5" disabled={working}>
122 <Ellipsis size={16} />
123 </Button>
124 </DropdownMenuTrigger>
125 <DropdownMenuContent align="end" className="w-56">
126 <DropdownMenuItem
127 disabled={!link}
128 onSelect={(event) => {
129 event.preventDefault();
130 if (!link) return;
131 void navigator.clipboard?.writeText(link).then(() => {
132 setCopied(true);
133 setTimeout(() => setCopied(false), 1500);
134 });
135 }}
136 >
137 {copied ? <Check /> : <Copy />}
138 {copied ? "Copied" : link ? "Copy link" : "No link to copy"}
139 </DropdownMenuItem>
140 <DropdownMenuItem disabled={!resendable} onSelect={() => fetcher.submit({ action: "resend-invite", id: invite.id }, { method: "post" })}>
141 <RotateCw />
142 {resendable ? "Send again" : "Already in their hands"}
143 </DropdownMenuItem>
144 <DropdownMenuSeparator />
145 <DropdownMenuItem disabled={!pending} className="text-danger" onSelect={() => fetcher.submit({ action: "revoke-invite", id: invite.id }, { method: "post" })}>
146 <X />
147 Revoke
148 </DropdownMenuItem>
149 </DropdownMenuContent>
150 </DropdownMenu>
151 );
152}
153
154export default function WorkspaceInvitations({ loaderData, params }: Route.ComponentProps) {
155 const { name, free, invites, origin } = loaderData;
156 const [search, setSearch] = useSearchParams();
157 const filter = invitationFilter(search.get("state"));
158 const counts = invitationCounts(invites);
159 const shown = filterInvitations(invites, filter);
160 const [notice, setNotice] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
161 const done = (result: InvitationResult) => {
162 const who = invites.find((invite) => invite.id === result.id);
163 const whom = who?.email ?? (who?.invitee ? `@${who.invitee}` : "the invitation");
164 if ("error" in result) setNotice({ tone: "error", text: result.error });
165 else setNotice({ tone: "ok", text: result.done === "revoked" ? `Revoked the invitation for ${whom}.` : `Sent the invitation for ${whom} again.` });
166 };
167 const choose = (value: string) => {
168 const next = new URLSearchParams(search);
169 if (value && value !== "all") next.set("state", value);
170 else next.delete("state");
171 setSearch(next, { replace: true, preventScrollReset: true });
172 };
173 return (
174 <div className="max-w-4xl">
175 <div className="mb-4 flex flex-wrap items-center gap-3">
176 <ToggleGroup type="single" variant="outline" value={filter} onValueChange={(value) => value && choose(value)} aria-label="Which invitations">
177 {INVITATION_FILTERS.map((entry) => (
178 <ToggleGroupItem key={entry.key} value={entry.key}>
179 {entry.label}
180 <span className="text-faint tabular-nums">{counts[entry.key]}</span>
181 </ToggleGroupItem>
182 ))}
183 </ToggleGroup>
184 {!free && (
185 <div className="ml-auto">
186 <InviteDialog
187 name={name}
188 action={`/${params.owner}/-/members/invitations`}
189 onInvited={(who) => setNotice({ tone: "ok", text: `Invitation to join ${name} sent to ${who}. They join once they accept.` })}
190 />
191 </div>
192 )}
193 </div>
194 {free && (
195 <div className="mb-4">
196 <StartPlanToInvite workspace={params.owner} owner />
197 </div>
198 )}
199 {shown.length === 0 ? (
200 <EmptyState title={filter === "all" ? "No invitations yet" : `No ${INVITATION_FILTERS.find((entry) => entry.key === filter)?.label.toLowerCase()} invitations`}>
201 {filter === "all"
202 ? `Invite someone and the invitation is listed here until they answer, and after.`
203 : "Choose All to see every invitation sent to join the workspace."}
204 </EmptyState>
205 ) : (
206 <Card tone="plain" className="overflow-hidden">
207 <Table>
208 <TableHeader>
209 <TableRow>
210 <TableHead>Who</TableHead>
211 <TableHead>Invited by</TableHead>
212 <TableHead>Role</TableHead>
213 <TableHead>Sent</TableHead>
214 <TableHead>Status</TableHead>
215 <TableHead className="w-10">
216 <span className="sr-only">Actions</span>
217 </TableHead>
218 </TableRow>
219 </TableHeader>
220 <TableBody>
221 {shown.map((invite) => {
222 const state = inviteState(invite);
223 return (
224 <TableRow key={invite.id}>
225 <TableCell className="max-w-64 align-middle text-sm">
226 <Who invite={invite} />
227 </TableCell>
228 <TableCell className="align-middle text-sm whitespace-nowrap text-muted">
229 {invite.invitedBy ? (
230 <UserCard username={invite.invitedBy}>
231 <Link to={`/u/${invite.invitedBy}`} className="font-mono hover:text-accent">
232 {invite.invitedBy}
233 </Link>
234 </UserCard>
235 ) : (
236 "g1t"
237 )}
238 </TableCell>
239 <TableCell className="align-middle text-sm whitespace-nowrap text-muted">{roleOf(invite)}</TableCell>
240 <TableCell className="align-middle text-sm whitespace-nowrap text-muted">
241 <TimeAgo at={invite.createdAt} />
242 </TableCell>
243 <TableCell className="align-middle">
244 <Badge tone={invitationTone(invite.status)} size="md" className="max-w-56 truncate">
245 {state.label}
246 </Badge>
247 {invitationFilterOf(invite.status) === "pending" && (
248 <span className="ml-2 text-xs whitespace-nowrap text-faint">until {invite.expiresAt.slice(0, 10)}</span>
249 )}
250 </TableCell>
251 <TableCell className="align-middle text-right">
252 <InvitationMenu invite={invite} origin={origin} onDone={done} />
253 </TableCell>
254 </TableRow>
255 );
256 })}
257 </TableBody>
258 </Table>
259 </Card>
260 )}
261 <p className="mt-2 text-xs text-faint">
262 An invitation works for 30 days. One nobody used by then shows as Expired; g1t does not yet know whether its email was delivered.
263 </p>
264 {notice && (notice.tone === "ok" ? <p className="mt-3 text-sm text-muted" role="status">{notice.text}</p> : <ErrorText>{notice.text}</ErrorText>)}
265 </div>
266 );
267}