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.

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