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.

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