Files
hope-events/frontend/src/app/dashboard/admin/users/page.tsx
T
joshuaandClaude Sonnet 5 f3e6525467 Add registration status badges and auto-dismissing dashboard messages
Two consistency fixes requested after the payment-method work:

1. Registration status (pending/confirmed/partial_paid/paid/cancelled)
   was printed as a raw string on the user dashboard. Added
   RegistrationStatusBadge mirroring the existing EventStatusBadge
   pattern, using the same status colors already established on
   dashboard/admin/registrations.

2. Inline success/error banners across dashboard pages persisted
   indefinitely. Added a shared useDismissingState hook (drop-in
   useState replacement that auto-clears a truthy value after 7s,
   resetting the timer on each update) and swapped it in across ~24
   dashboard files. Excluded: message-only modal dialogs (ticket-
   scanning's success/error confirmations) and two states that mix
   live form-validation feedback with async results inside actively-
   open forms (the registration-edit modal's editError, the event
   create/edit modal's error) - those keep persisting until the user
   acts, since auto-hiding a "fix this field" message mid-edit would
   be a regression. Also fixed at-the-door's existing bespoke
   auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same
   consistent 7s, and removed admin/settings' manual x dismiss button
   in favor of the same auto-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:17:53 +02:00

436 lines
19 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";
interface UserItem {
id: string;
name: string;
email: string;
role: string;
phoneNumber?: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
const roleOptions = ["user", "staff", "supervisor", "admin"] as const;
type Role = typeof roleOptions[number];
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
function fuzzyMatch(query: string, target: string): boolean {
const q = query.toLowerCase();
const t = target.toLowerCase();
if (t.includes(q)) return true;
// token-based: all tokens must appear somewhere
const tokens = q.split(/\s+/).filter(Boolean);
return tokens.every(tok => t.includes(tok));
}
export default function AdminUsersPage() {
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]);
// Data state
const [users, setUsers] = useState<UserItem[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [pageSize, setPageSize] = useState(50);
// Search and filter state (server-side)
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [roleFilter, setRoleFilter] = useState<Role | "">("");
const [activeFilter, setActiveFilter] = useState<"" | "true" | "false">("");
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query), 350);
return () => clearTimeout(t);
}, [query]);
// Create form state
const [createOpen, setCreateOpen] = useState(false);
const [cName, setCName] = useState("");
const [cEmail, setCEmail] = useState("");
const [cPassword, setCPassword] = useState("");
const [cPhone, setCPhone] = useState("");
const [cRole, setCRole] = useState<Role>("user");
const [creating, setCreating] = useState(false);
// Inline edit state
const [editingId, setEditingId] = useState<string | null>(null);
const [editData, setEditData] = useState<Partial<UserItem> & { password?: string }>({});
const [saving, setSaving] = useState(false);
const buildQuery = useCallback((p: number, ps = pageSize) => {
const qs = new URLSearchParams({ page: String(p), limit: String(ps) });
if (debouncedQuery.trim()) qs.set("search", debouncedQuery.trim());
if (roleFilter) qs.set("role", roleFilter);
if (activeFilter !== "") qs.set("isActive", activeFilter);
return `/api/users?${qs.toString()}`;
}, [debouncedQuery, roleFilter, activeFilter, pageSize]);
const loadUsers = useCallback(async (p = 1, ps = pageSize) => {
if (!token) return;
setError(null);
setFetching(true);
try {
const url = buildQuery(p, ps);
const res = await apiFetch<any>(url, { authToken: token });
setUsers(Array.isArray(res?.data) ? res.data : []);
setTotal(res?.total ?? 0);
setTotalPages(res?.pages ?? 1);
setPage(p);
} catch (e: any) {
setError(e?.message || "Failed to load users");
} finally {
setFetching(false);
}
}, [token, buildQuery]);
// Initial load + reload on filter change
useEffect(() => { if (token) loadUsers(1); }, [token, debouncedQuery, roleFilter, activeFilter]);
const goToPage = (p: number) => loadUsers(p);
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
loadUsers(1, newSize);
};
const resetCreateForm = () => {
setCName(""); setCEmail(""); setCPassword(""); setCPhone(""); setCRole("user");
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
try {
setCreating(true);
const created = await apiFetch<any>("/api/users", {
method: "POST",
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
});
if (cRole && cRole !== "user" && created?.id) {
await apiFetch(`/api/users/${encodeURIComponent(created.id)}`, {
method: "PUT", authToken: token!, body: { role: cRole },
});
}
resetCreateForm();
setCreateOpen(false);
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to create user");
} finally {
setCreating(false);
}
};
const startEdit = (u: UserItem) => { setEditingId(u.id); setEditData({ ...u, password: "" }); };
const cancelEdit = () => { setEditingId(null); setEditData({}); };
const saveEdit = async () => {
if (!editingId) return;
try {
setSaving(true);
const payload: any = {
name: editData.name,
email: editData.email,
role: editData.role,
phoneNumber: editData.phoneNumber || null,
isActive: editData.isActive,
};
if (editData.password && editData.password.trim().length > 0) {
payload.password = editData.password.trim();
}
await apiFetch(`/api/users/${encodeURIComponent(editingId)}`, {
method: "PUT", authToken: token!, body: payload,
});
setEditingId(null);
setEditData({});
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to save user");
} finally {
setSaving(false);
}
};
const revokeUserSessions = async (id: string, name: string) => {
if (!confirm(`Revoke all active sessions for ${name}? They will be signed out on all devices.`)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/revoke-sessions`, { method: "POST", authToken: token! });
} catch (e: any) {
setError(e?.message || "Failed to revoke sessions");
}
};
const deactivate = async (id: string) => {
if (!confirm("Deactivate this user? Their account will be disabled.")) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! });
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to deactivate user");
}
};
const deleteUserData = async (id: string, name: string) => {
if (!confirm(`Delete personal data for "${name}"?\n\nThis will set their name to "Deleted User", clear their email and phone number, and deactivate the account. This action cannot be undone.`)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/anonymize`, { method: "POST", authToken: token! });
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to delete user data");
}
};
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">User Management</h1>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => setCreateOpen(v => !v)}>
{createOpen ? "Close" : "Create user"}
</button>
</div>
</div>
{!isAdmin && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need admin access to manage users.
</div>
)}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{createOpen && (
<form onSubmit={handleCreate} className="border rounded-xl p-4 bg-white shadow-sm mb-6 grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-700 mb-1">Name</label>
<input className="w-full border rounded px-3 py-2" value={cName} onChange={e => setCName(e.target.value)} />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Email</label>
<input type="email" className="w-full border rounded px-3 py-2" value={cEmail} onChange={e => setCEmail(e.target.value)} required />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Password</label>
<input type="password" className="w-full border rounded px-3 py-2" value={cPassword} onChange={e => setCPassword(e.target.value)} required />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Phone</label>
<input className="w-full border rounded px-3 py-2" value={cPhone} onChange={e => setCPhone(e.target.value)} />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Role</label>
<select className="w-full border rounded px-3 py-2" value={cRole} onChange={e => setCRole(e.target.value as Role)}>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div className="flex items-end">
<button disabled={creating} className="px-3 py-2 rounded bg-indigo-600 text-white disabled:opacity-50" type="submit">
{creating ? "Creating…" : "Create"}
</button>
</div>
</form>
)}
<div className="border rounded-xl p-4 bg-white shadow-sm">
{/* Filters row */}
<div className="flex flex-wrap items-end gap-3 mb-4">
<div className="flex-1 min-w-48">
<label className="block text-xs text-gray-600 mb-1">Search (name, email, phone)</label>
<input
className="w-full border rounded px-3 py-1.5 text-sm"
placeholder="Type to search…"
value={query}
onChange={e => setQuery(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Role</label>
<select className="border rounded px-2 py-1.5 text-sm" value={roleFilter} onChange={e => setRoleFilter(e.target.value as Role | "")}>
<option value="">All roles</option>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Status</label>
<select className="border rounded px-2 py-1.5 text-sm" value={activeFilter} onChange={e => setActiveFilter(e.target.value as "" | "true" | "false")}>
<option value="">Active &amp; inactive</option>
<option value="true">Active only</option>
<option value="false">Inactive only</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Per page</label>
<select className="border rounded px-2 py-1.5 text-sm" value={pageSize} onChange={e => handlePageSizeChange(Number(e.target.value))}>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => loadUsers(page)} disabled={fetching}>
{fetching ? "Refreshing…" : "Refresh"}
</button>
</div>
<div className="text-xs text-gray-500 mb-2">
{total} user{total !== 1 ? "s" : ""} total
{total > 0 && ` — page ${page} of ${totalPages}`}
</div>
<div className="overflow-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-600 border-b">
<th className="p-2">Name</th>
<th className="p-2">Email</th>
<th className="p-2">Role</th>
<th className="p-2">Phone</th>
<th className="p-2">Active</th>
<th className="p-2">Password</th>
<th className="p-2">Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className="border-t hover:bg-gray-50">
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-44" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
) : (
<span className={`font-medium ${!u.isActive ? "text-gray-400" : ""}`}>{u.name}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-60" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
) : (
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<select className="border rounded px-2 py-1" value={(editData.role as Role) || (u.role as Role)} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
) : (
<span className="capitalize">{u.role}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-36" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
) : (
<span>{u.phoneNumber || ""}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
) : (
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<input type="password" placeholder="Set new password" className="border rounded px-2 py-1 w-44" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<div className="flex gap-2">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
<button className="px-2 py-1 text-xs rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
) : (
<div className="flex gap-1 flex-wrap">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Sign out all devices">Sessions</button>
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
</div>
)}
</td>
</tr>
))}
{users.length === 0 && !fetching && (
<tr>
<td className="p-3 text-gray-500" colSpan={7}>No users found.</td>
</tr>
)}
{fetching && (
<tr>
<td className="p-3 text-gray-400" colSpan={7}>Loading</td>
</tr>
)}
</tbody>
</table>
</div>
{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={() => goToPage(page - 1)}
>
Prev
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
.reduce<(number | "…")[]>((acc, p, i, arr) => {
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === "…" ? (
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-400"></span>
) : (
<button
key={p}
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
disabled={fetching}
onClick={() => goToPage(p as number)}
>
{p}
</button>
)
)}
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
disabled={page >= totalPages || fetching}
onClick={() => goToPage(page + 1)}
>
Next
</button>
</div>
</div>
)}
</div>
</div>
);
}