Six site improvements picked from a "what could be better" review, plus a Jest test suite covering the two areas with the trickiest money-handling history in this project (early-bird pricing tranches, donation-leg accounting): - "Add to calendar" .ics download on event pages and in confirmation emails - sitemap.xml, robots.txt, and Open Graph/Twitter metadata for public pages - Sentry error monitoring (backend + frontend), a no-op until SENTRY_DSN is set - Nightly local pg_dump backups with a Site Settings tab to browse/trigger/download - Admin audit trail for refunds, donations, manual registrations, event and settings changes, and staff-initiated cancellations - Jest tests reproducing and guarding against the 1.8.0 tranche-pricing bug and the 1.4.2 donation-balance-inflation bug Wallet passes (Google/Apple) were scoped out of this round — Apple Wallet needs a paid Apple Developer account the project doesn't have yet, and the user preferred shipping both together later rather than Google alone now. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
219 lines
8.2 KiB
TypeScript
219 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useRouter } from "next/navigation";
|
|
import { apiFetch } from "@/lib/api";
|
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
|
import { History } from "lucide-react";
|
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
|
|
|
const ACTIONS = [
|
|
"refund_created",
|
|
"donation_assigned",
|
|
"donation_unassigned",
|
|
"registration_created_manual",
|
|
"registration_cancelled",
|
|
"event_created",
|
|
"event_updated",
|
|
"event_deleted",
|
|
"settings_updated",
|
|
] as const;
|
|
|
|
const ACTION_LABELS: Record<string, string> = {
|
|
refund_created: "Refund created",
|
|
donation_assigned: "Donation assigned",
|
|
donation_unassigned: "Donation unassigned",
|
|
registration_created_manual: "Manual registration created",
|
|
registration_cancelled: "Registration cancelled (staff)",
|
|
event_created: "Event created",
|
|
event_updated: "Event updated",
|
|
event_deleted: "Event deactivated",
|
|
settings_updated: "Settings updated",
|
|
};
|
|
|
|
interface AuditLogEntry {
|
|
id: string;
|
|
actorId: string | null;
|
|
actorRole: string;
|
|
action: string;
|
|
targetType: string;
|
|
targetId: string | null;
|
|
metadata: Record<string, unknown> | null;
|
|
ip: string | null;
|
|
createdAt: string;
|
|
actor: { id: string; name: string; email: string } | null;
|
|
}
|
|
|
|
function formatMetadata(metadata: Record<string, unknown> | null): string {
|
|
if (!metadata) return "";
|
|
try {
|
|
return Object.entries(metadata)
|
|
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
|
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : String(v)}`)
|
|
.join(" · ");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export default function AdminAuditLogPage() {
|
|
const { user, loading, token } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const isAdmin = useMemo(() => user?.role === "admin", [user]);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (!user) router.replace("/login");
|
|
}, [user, loading, router]);
|
|
|
|
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
|
const [fetching, setFetching] = useState(false);
|
|
const [error, setError] = useDismissingState<string | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const pageSize = 50;
|
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
|
|
const [actionFilter, setActionFilter] = useState<string>("");
|
|
const [fromFilter, setFromFilter] = useState<string>("");
|
|
const [toFilter, setToFilter] = useState<string>("");
|
|
|
|
const buildQuery = useCallback((p: number) => {
|
|
const qs = new URLSearchParams({ page: String(p), limit: String(pageSize) });
|
|
if (actionFilter) qs.set("action", actionFilter);
|
|
if (fromFilter) qs.set("from", fromFilter);
|
|
if (toFilter) qs.set("to", toFilter);
|
|
return `/api/admin/audit-log?${qs.toString()}`;
|
|
}, [actionFilter, fromFilter, toFilter]);
|
|
|
|
const load = useCallback(async (p = 1) => {
|
|
if (!token) return;
|
|
setError(null);
|
|
setFetching(true);
|
|
try {
|
|
const res = await apiFetch<{ rows: AuditLogEntry[]; total: number; page: number }>(buildQuery(p), { authToken: token });
|
|
setEntries(res?.rows || []);
|
|
setTotal(res?.total ?? 0);
|
|
setPage(p);
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to load audit log");
|
|
} finally {
|
|
setFetching(false);
|
|
}
|
|
}, [token, buildQuery]);
|
|
|
|
useEffect(() => { if (token) load(1); }, [token, actionFilter, fromFilter, toFilter]);
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto w-full p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
|
<History className="w-5 h-5 text-brand-600" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-gray-900">Admin Audit Log</h1>
|
|
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} recorded</p>
|
|
</div>
|
|
</div>
|
|
|
|
{!isAdmin && (
|
|
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
|
You need admin access to view the audit log.
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
|
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
|
<div className="flex flex-wrap items-end gap-3 mb-4">
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Action</label>
|
|
<select className="border rounded px-2 py-1.5 text-sm" value={actionFilter} onChange={e => setActionFilter(e.target.value)}>
|
|
<option value="">All actions</option>
|
|
{ACTIONS.map(a => <option key={a} value={a}>{ACTION_LABELS[a]}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">From</label>
|
|
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={fromFilter} onChange={e => setFromFilter(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">To</label>
|
|
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={toFilter} onChange={e => setToFilter(e.target.value)} />
|
|
</div>
|
|
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => load(page)} disabled={fetching}>
|
|
{fetching ? "Refreshing…" : "Refresh"}
|
|
</button>
|
|
</div>
|
|
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>When</TableHead>
|
|
<TableHead>Actor</TableHead>
|
|
<TableHead>Action</TableHead>
|
|
<TableHead>Target</TableHead>
|
|
<TableHead>Details</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{entries.map(e => (
|
|
<TableRow key={e.id}>
|
|
<TableCell className="whitespace-nowrap text-gray-600">{new Date(e.createdAt).toLocaleString()}</TableCell>
|
|
<TableCell>
|
|
{e.actor ? (
|
|
<>
|
|
<div className="font-medium">{e.actor.name}</div>
|
|
<div className="text-xs text-gray-500">{e.actor.email}</div>
|
|
</>
|
|
) : (
|
|
<span className="text-gray-400 italic">Deleted user</span>
|
|
)}
|
|
<div className="text-xs text-gray-400 capitalize">{e.actorRole}</div>
|
|
</TableCell>
|
|
<TableCell>{ACTION_LABELS[e.action] || e.action}</TableCell>
|
|
<TableCell className="text-xs text-gray-600">{e.targetType}{e.targetId ? ` #${e.targetId.slice(0, 8)}` : ""}</TableCell>
|
|
<TableCell className="text-xs text-gray-500 max-w-[280px] truncate" title={formatMetadata(e.metadata)}>{formatMetadata(e.metadata)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{entries.length === 0 && !fetching && (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-gray-500">No matching audit entries.</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{fetching && (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-gray-400">Loading…</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-4 text-sm">
|
|
<span className="text-gray-500">Page {page} of {totalPages}</span>
|
|
<div className="flex gap-1">
|
|
<button
|
|
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
|
disabled={page <= 1 || fetching}
|
|
onClick={() => load(page - 1)}
|
|
>
|
|
← Prev
|
|
</button>
|
|
<button
|
|
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
|
disabled={page >= totalPages || fetching}
|
|
onClick={() => load(page + 1)}
|
|
>
|
|
Next →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|