import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { Plus, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { InviteForm, type InviteFormValues } from "@/client/components/admin/invite-form"; import { Button } from "@/client/components/ui/button"; import { Card } from "@/client/components/ui/card"; import { ConfirmButton } from "@/client/components/ui/confirm-button"; import { PageError } from "@/client/components/ui/page-error"; import { PageHeader } from "@/client/components/ui/page-header"; import { ListRow } from "@/client/components/ui/row"; import { SecretRevealCard } from "@/client/components/ui/secret-reveal-card"; import { useRequireAdmin } from "@/client/hooks/use-auth-guard"; import { useResourceForm } from "@/client/hooks/use-resource-form"; import { formatDate, formatDateTime } from "@/client/lib/format"; import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http"; import { queryKeys } from "@/client/lib/query"; import { hasAdminRole } from "@/shared/role"; interface Invite { id: string; email: string; createdAt: string; expiresAt: string; consumedAt: string | null; } type InviteStatus = "pending" | "consumed" | "expired"; const STATUS_ORDER: Record = { pending: 0, expired: 1, consumed: 2 }; const STATUS_LABEL: Record = { pending: "Pending", consumed: "Consumed", expired: "Expired", }; const STATUS_TONE: Record = { pending: "text-zinc-200", consumed: "text-zinc-500", expired: "text-zinc-500", }; const statusOf = (invite: Invite): InviteStatus => { if (invite.consumedAt) return "consumed"; if (Date.parse(invite.expiresAt) < Date.now()) return "expired"; return "pending"; }; export const AdminInvitesPage = () => { const session = useRequireAdmin("/admin/invites"); const queryClient = useQueryClient(); const [submitting, setSubmitting] = useState(false); const [lastUrl, setLastUrl] = useState(null); const [error, setError] = useState(null); const form = useResourceForm({ canCancel: () => !submitting }); const isAdmin = hasAdminRole(session.data?.user.role); const userId = session.data?.user.id ?? null; const isLocked = submitting || form.creating || lastUrl !== null; const invitesQueryKey = queryKeys.adminInvites(userId); const invitesQuery = useInfiniteQuery({ queryKey: invitesQueryKey, queryFn: async ({ pageParam }: { pageParam: string | null }) => { const url = pageParam ? `/api/admin/invites?cursor=${encodeURIComponent(pageParam)}` : "/api/admin/invites"; const res = await apiRequest(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()) as { invites: Invite[]; nextCursor: string | null }; }, initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.nextCursor, enabled: isAdmin, }); const invites = useMemo(() => invitesQuery.data?.pages.flatMap((page) => page.invites) ?? [], [invitesQuery.data]); const hasNextPage = invitesQuery.hasNextPage; const loadingMore = invitesQuery.isFetchingNextPage; const loadMore = async () => { if (!invitesQuery.hasNextPage || invitesQuery.isFetchingNextPage) return; await invitesQuery.fetchNextPage(); }; const sortedInvites = useMemo( () => [...invites].sort((a, b) => { const byStatus = STATUS_ORDER[statusOf(a)] - STATUS_ORDER[statusOf(b)]; if (byStatus !== 0) return byStatus; return Date.parse(b.createdAt) - Date.parse(a.createdAt); }), [invites], ); const handleCreate = async (values: InviteFormValues) => { if (submitting) return; form.setFormError(null); setSubmitting(true); try { const res = await apiJsonRequest( "/api/admin/invites", { email: values.email, expiresInDays: values.days }, { method: "POST" }, ); if (!res.ok) { form.setFormError(await responseMessage(res, "Could not create invite.")); return; } const body = (await res.json()) as { inviteUrl: string }; setLastUrl(body.inviteUrl); form.close(); await queryClient.resetQueries({ queryKey: invitesQueryKey, exact: true }); } finally { setSubmitting(false); } }; const handleRevoke = async (id: string) => { await apiRequest(`/api/admin/invites/${id}`, { method: "DELETE" }); await queryClient.resetQueries({ queryKey: invitesQueryKey, exact: true }); }; return (
); };