Skip to content
File

Blob: src/client/pages/admin/invites.tsx

typescript213 lines
1import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
2import { Plus, Trash2 } from "lucide-react";
3import { useMemo, useState } from "react";
4 
5import { InviteForm, type InviteFormValues } from "@/client/components/admin/invite-form";
6import { Button } from "@/client/components/ui/button";
7import { Card } from "@/client/components/ui/card";
8import { ConfirmButton } from "@/client/components/ui/confirm-button";
9import { PageError } from "@/client/components/ui/page-error";
10import { PageHeader } from "@/client/components/ui/page-header";
11import { ListRow } from "@/client/components/ui/row";
12import { SecretRevealCard } from "@/client/components/ui/secret-reveal-card";
13import { useRequireAdmin } from "@/client/hooks/use-auth-guard";
14import { useResourceForm } from "@/client/hooks/use-resource-form";
15import { formatDate, formatDateTime } from "@/client/lib/format";
16import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http";
17import { queryKeys } from "@/client/lib/query";
18import { hasAdminRole } from "@/shared/role";
19 
20interface Invite {
21 id: string;
22 email: string;
23 createdAt: string;
24 expiresAt: string;
25 consumedAt: string | null;
26}
27 
28type InviteStatus = "pending" | "consumed" | "expired";
29 
30const STATUS_ORDER: Record<InviteStatus, number> = { pending: 0, expired: 1, consumed: 2 };
31const STATUS_LABEL: Record<InviteStatus, string> = {
32 pending: "Pending",
33 consumed: "Consumed",
34 expired: "Expired",
35};
36const STATUS_TONE: Record<InviteStatus, string> = {
37 pending: "text-zinc-200",
38 consumed: "text-zinc-500",
39 expired: "text-zinc-500",
40};
41 
42const statusOf = (invite: Invite): InviteStatus => {
43 if (invite.consumedAt) return "consumed";
44 if (Date.parse(invite.expiresAt) < Date.now()) return "expired";
45 return "pending";
46};
47 
48export const AdminInvitesPage = () => {
49 const session = useRequireAdmin("/admin/invites");
50 const queryClient = useQueryClient();
51 const [submitting, setSubmitting] = useState(false);
52 const [lastUrl, setLastUrl] = useState<string | null>(null);
53 const [error, setError] = useState<string | null>(null);
54 const form = useResourceForm({ canCancel: () => !submitting });
55 const isAdmin = hasAdminRole(session.data?.user.role);
56 const userId = session.data?.user.id ?? null;
57 const isLocked = submitting || form.creating || lastUrl !== null;
58 const invitesQueryKey = queryKeys.adminInvites(userId);
59 
60 const invitesQuery = useInfiniteQuery({
61 queryKey: invitesQueryKey,
62 queryFn: async ({ pageParam }: { pageParam: string | null }) => {
63 const url = pageParam ? `/api/admin/invites?cursor=${encodeURIComponent(pageParam)}` : "/api/admin/invites";
64 const res = await apiRequest(url);
65 if (!res.ok) throw new Error(`HTTP ${res.status}`);
66 return (await res.json()) as { invites: Invite[]; nextCursor: string | null };
67 },
68 initialPageParam: null as string | null,
69 getNextPageParam: (lastPage) => lastPage.nextCursor,
70 enabled: isAdmin,
71 });
72 const invites = useMemo(() => invitesQuery.data?.pages.flatMap((page) => page.invites) ?? [], [invitesQuery.data]);
73 const hasNextPage = invitesQuery.hasNextPage;
74 const loadingMore = invitesQuery.isFetchingNextPage;
75 
76 const loadMore = async () => {
77 if (!invitesQuery.hasNextPage || invitesQuery.isFetchingNextPage) return;
78 await invitesQuery.fetchNextPage();
79 };
80 
81 const sortedInvites = useMemo(
82 () =>
83 [...invites].sort((a, b) => {
84 const byStatus = STATUS_ORDER[statusOf(a)] - STATUS_ORDER[statusOf(b)];
85 if (byStatus !== 0) return byStatus;
86 return Date.parse(b.createdAt) - Date.parse(a.createdAt);
87 }),
88 [invites],
89 );
90 
91 const handleCreate = async (values: InviteFormValues) => {
92 if (submitting) return;
93 form.setFormError(null);
94 setSubmitting(true);
95 try {
96 const res = await apiJsonRequest(
97 "/api/admin/invites",
98 { email: values.email, expiresInDays: values.days },
99 { method: "POST" },
100 );
101 if (!res.ok) {
102 form.setFormError(await responseMessage(res, "Could not create invite."));
103 return;
104 }
105 const body = (await res.json()) as { inviteUrl: string };
106 setLastUrl(body.inviteUrl);
107 form.close();
108 await queryClient.resetQueries({ queryKey: invitesQueryKey, exact: true });
109 } finally {
110 setSubmitting(false);
111 }
112 };
113 
114 const handleRevoke = async (id: string) => {
115 await apiRequest(`/api/admin/invites/${id}`, { method: "DELETE" });
116 await queryClient.resetQueries({ queryKey: invitesQueryKey, exact: true });
117 };
118 
119 return (
120 <div className="mx-auto max-w-3xl">
121 <PageHeader
122 title="Invites"
123 actions={
124 <Button size="sm" onClick={form.startCreate} disabled={isLocked}>
125 <Plus className="h-4 w-4" aria-hidden="true" />
126 New invite
127 </Button>
128 }
129 />
130 <PageError message={error} onDismiss={() => setError(null)} />
131 {lastUrl ? (
132 <div className="mb-6">
133 <SecretRevealCard
134 eyebrow="New invite — copy now, this is the only time it's shown"
135 lines={[{ label: "url", value: lastUrl }]}
136 copyLabel="Copy invite URL"
137 onDismiss={() => setLastUrl(null)}
138 />
139 </div>
140 ) : null}
141
142 {form.creating ? (
143 <Card className="mb-4">
144 <InviteForm
145 pending={submitting}
146 error={form.formError}
147 onDismissError={() => form.setFormError(null)}
148 onSubmit={handleCreate}
149 onCancel={form.cancel}
150 />
151 </Card>
152 ) : null}
153
154 <Card>
155 {sortedInvites.length === 0 ? (
156 <p className="text-sm text-zinc-500">No invites yet.</p>
157 ) : (
158 <>
159 <ul className="flex flex-col gap-2">
160 {sortedInvites.map((invite) => {
161 const status = statusOf(invite);
162 return (
163 <ListRow
164 key={invite.id}
165 className="flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between"
166 >
167 <div className="min-w-0">
168 <div className="flex items-baseline gap-3">
169 <span
170 className={`shrink-0 text-[10px] font-medium uppercase tracking-[0.18em] ${STATUS_TONE[status]}`}
171 >
172 {STATUS_LABEL[status]}
173 </span>
174 <p className="truncate text-zinc-100">{invite.email}</p>
175 </div>
176 <p className="mt-1 text-xs text-zinc-500">
177 {status === "consumed"
178 ? `Consumed ${formatDate(invite.consumedAt!)}`
179 : status === "expired"
180 ? `Expired ${formatDateTime(invite.expiresAt)}`
181 : `Expires ${formatDateTime(invite.expiresAt)}`}
182 </p>
183 </div>
184 {status === "pending" ? (
185 <ConfirmButton
186 variant="ghost"
187 size="sm"
188 aria-label="Revoke invite"
189 title="Revoke invite"
190 className="self-start sm:self-auto"
191 onConfirm={() => handleRevoke(invite.id)}
192 >
193 <Trash2 className="h-4 w-4" aria-hidden="true" />
194 </ConfirmButton>
195 ) : null}
196 </ListRow>
197 );
198 })}
199 </ul>
200 {hasNextPage ? (
201 <div className="mt-4">
202 <Button variant="ghost" size="sm" onClick={loadMore} loading={loadingMore}>
203 Load more
204 </Button>
205 </div>
206 ) : null}
207 </>
208 )}
209 </Card>
210 </div>
211 );
212};