"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([]); const [fetching, setFetching] = useState(false); const [error, setError] = useDismissingState(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(""); 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("user"); const [creating, setCreating] = useState(false); // Inline edit state const [editingId, setEditingId] = useState(null); const [editData, setEditData] = useState & { 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(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("/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 (

User Management

{!isAdmin && (
You need admin access to manage users.
)} {error &&
{error}
} {createOpen && (
setCName(e.target.value)} />
setCEmail(e.target.value)} required />
setCPassword(e.target.value)} required />
setCPhone(e.target.value)} />
)}
{/* Filters row */}
setQuery(e.target.value)} />
{total} user{total !== 1 ? "s" : ""} total {total > 0 && ` — page ${page} of ${totalPages}`}
{users.map(u => ( ))} {users.length === 0 && !fetching && ( )} {fetching && ( )}
Name Email Role Phone Active Password Actions
{editingId === u.id ? ( setEditData(d => ({ ...d, name: e.target.value }))} /> ) : ( {u.name} )} {editingId === u.id ? ( setEditData(d => ({ ...d, email: e.target.value }))} /> ) : ( {u.email} )} {editingId === u.id ? ( ) : ( {u.role} )} {editingId === u.id ? ( setEditData(d => ({ ...d, phoneNumber: e.target.value }))} /> ) : ( {u.phoneNumber || ""} )} {editingId === u.id ? ( setEditData(d => ({ ...d, isActive: e.target.checked }))} /> ) : ( {u.isActive ? "Yes" : "No"} )} {editingId === u.id ? ( setEditData(d => ({ ...d, password: e.target.value }))} /> ) : ( )} {editingId === u.id ? (
) : (
)}
No users found.
Loading…
{totalPages > 1 && (
Page {page} of {totalPages}
{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 === "…" ? ( ) : ( ) )}
)}
); }