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