File
Blob: src/client/pages/admin/invites.tsx
| 1 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; |
| 2 | import { Plus, Trash2 } from "lucide-react"; |
| 3 | import { useMemo, useState } from "react"; |
| 4 | |
| 5 | import { InviteForm, type InviteFormValues } from "@/client/components/admin/invite-form"; |
| 6 | import { Button } from "@/client/components/ui/button"; |
| 7 | import { Card } from "@/client/components/ui/card"; |
| 8 | import { ConfirmButton } from "@/client/components/ui/confirm-button"; |
| 9 | import { PageError } from "@/client/components/ui/page-error"; |
| 10 | import { PageHeader } from "@/client/components/ui/page-header"; |
| 11 | import { ListRow } from "@/client/components/ui/row"; |
| 12 | import { SecretRevealCard } from "@/client/components/ui/secret-reveal-card"; |
| 13 | import { useRequireAdmin } from "@/client/hooks/use-auth-guard"; |
| 14 | import { useResourceForm } from "@/client/hooks/use-resource-form"; |
| 15 | import { formatDate, formatDateTime } from "@/client/lib/format"; |
| 16 | import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http"; |
| 17 | import { queryKeys } from "@/client/lib/query"; |
| 18 | import { hasAdminRole } from "@/shared/role"; |
| 19 | |
| 20 | interface Invite { |
| 21 | id: string; |
| 22 | email: string; |
| 23 | createdAt: string; |
| 24 | expiresAt: string; |
| 25 | consumedAt: string | null; |
| 26 | } |
| 27 | |
| 28 | type InviteStatus = "pending" | "consumed" | "expired"; |
| 29 | |
| 30 | const STATUS_ORDER: Record<InviteStatus, number> = { pending: 0, expired: 1, consumed: 2 }; |
| 31 | const STATUS_LABEL: Record<InviteStatus, string> = { |
| 32 | pending: "Pending", |
| 33 | consumed: "Consumed", |
| 34 | expired: "Expired", |
| 35 | }; |
| 36 | const STATUS_TONE: Record<InviteStatus, string> = { |
| 37 | pending: "text-zinc-200", |
| 38 | consumed: "text-zinc-500", |
| 39 | expired: "text-zinc-500", |
| 40 | }; |
| 41 | |
| 42 | const 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 | |
| 48 | export 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 | }; |