Fix broken admin actions: delete cascade, error feedback, loading states

Users page:
- Delete was silently failing with FK constraint violations — sessions,
  accounts, support_tickets and family_members all have ON DELETE no action
  refs to users. Now cascades in correct order before deleting the user.
- Added error/success toast notifications so failures are visible
- Delete button shows loading spinner while in-flight; all buttons
  disabled during operation to prevent double-submit
- Always optimistically removes row on success (no full refetch needed)

Families page:
- Replaced browser prompt() for "New Family" with inline form — prompt()
  is blocked in some environments (CSP, iframes, browser settings)
- Fixed role-before-email bug: role dropdown was silently lost when changed
  before typing email, because onChange reset the whole addMember state.
  Now uses per-family form state with stable field updates.
- Remove member button shows loading spinner; disabled during operation
- Tier change button shows loading; disabled during other tier changes
- Added error/success toast notifications for all actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 08:59:20 +05:30
parent 23010e9d90
commit c2695435f2
3 changed files with 317 additions and 189 deletions

View file

@ -29,77 +29,104 @@ export default function AdminFamilies() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tierFilter, setTierFilter] = useState("all"); const [tierFilter, setTierFilter] = useState("all");
const [showMembers, setShowMembers] = useState<string | null>(null); const [showMembers, setShowMembers] = useState<string | null>(null);
const [addMember, setAddMember] = useState<{ familyId: string; email: string; role: string; name: string } | null>(null);
const [tierChanging, setTierChanging] = useState<string | null>(null); const [tierChanging, setTierChanging] = useState<string | null>(null);
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// New family form
const [showNewFamily, setShowNewFamily] = useState(false);
const [newFamilyName, setNewFamilyName] = useState("");
const [creatingFamily, setCreatingFamily] = useState(false);
// Add member form — separate email/role state to avoid lost-role bug
const [memberForms, setMemberForms] = useState<Record<string, { email: string; role: string }>>({});
useEffect(() => { useEffect(() => {
fetchFamilies(); fetchFamilies();
}, []); }, []);
const showMessage = (msg: string, type: "success" | "error") => {
if (type === "success") { setSuccess(msg); setTimeout(() => setSuccess(null), 3000); }
else { setError(msg); setTimeout(() => setError(null), 5000); }
};
const fetchFamilies = async () => { const fetchFamilies = async () => {
try { try {
const res = await fetch("/api/admin/families", { credentials: "include" }); const res = await fetch("/api/admin/families", { credentials: "include" });
const data = await res.json(); const data = await res.json();
if (data.error) console.error("API error:", data.error); if (data.error) throw new Error(data.error);
setFamilies(data.families || []); setFamilies(data.families || []);
} catch (err) { } catch (err: any) {
console.error("Failed to fetch families:", err); showMessage(err.message || "Failed to load families", "error");
} }
setLoading(false); setLoading(false);
}; };
const handleCreateFamily = async () => { const handleCreateFamily = async () => {
const name = prompt("Family name:"); if (!newFamilyName.trim()) return;
if (!name) return; setCreatingFamily(true);
try { try {
const res = await fetch("/api/admin/families", { const res = await fetch("/api/admin/families", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify({ name }), body: JSON.stringify({ name: newFamilyName.trim() }),
}); });
if (res.ok) fetchFamilies(); const data = await res.json();
} catch (err) { if (!res.ok) throw new Error(data.error || "Failed to create family");
console.error("Failed to create:", err); showMessage(`Family "${newFamilyName.trim()}" created`, "success");
setNewFamilyName("");
setShowNewFamily(false);
fetchFamilies();
} catch (err: any) {
showMessage(err.message, "error");
} }
setCreatingFamily(false);
}; };
const handleAddMember = async () => { const handleAddMember = async (familyId: string) => {
if (!addMember?.email || !addMember?.familyId) return; const form = memberForms[familyId];
if (!form?.email) return;
try { try {
const res = await fetch("/api/admin/families", { const res = await fetch("/api/admin/families", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(addMember), body: JSON.stringify({ familyId, email: form.email, role: form.role || "caregiver", displayName: form.email }),
}); });
if (res.ok) { const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to add member");
showMessage("Member added", "success");
setMemberForms(prev => ({ ...prev, [familyId]: { email: "", role: "caregiver" } }));
fetchFamilies(); fetchFamilies();
setAddMember(null); } catch (err: any) {
} showMessage(err.message, "error");
} catch (err) {
console.error("Failed to add member:", err);
} }
}; };
const handleRemoveMember = async (memberId: string) => { const handleRemoveMember = async (memberId: string) => {
if (!confirm("Remove this member?")) return; if (!window.confirm("Remove this member from the family?")) return;
setRemovingMemberId(memberId);
try { try {
const res = await fetch(`/api/admin/families?memberId=${memberId}`, { const res = await fetch(`/api/admin/families?memberId=${memberId}`, {
method: "DELETE", method: "DELETE",
credentials: "include", credentials: "include",
}); });
if (res.ok) fetchFamilies(); const data = await res.json();
} catch (err) { if (!res.ok) throw new Error(data.error || "Failed to remove member");
console.error("Failed to remove:", err); showMessage("Member removed", "success");
fetchFamilies();
} catch (err: any) {
showMessage(err.message, "error");
} }
setRemovingMemberId(null);
}; };
const handleTierChange = async (familyId: string, newTier: string) => { const handleTierChange = async (familyId: string, currentTier: string) => {
const family = families.find(f => f.id === familyId); const newTier = currentTier === "pro" ? "free" : "pro";
if (!family) return;
const label = newTier === "pro" ? "Upgrade to Pro?" : "Downgrade to Free?"; const label = newTier === "pro" ? "Upgrade to Pro?" : "Downgrade to Free?";
if (!confirm(label)) return; if (!window.confirm(label)) return;
setTierChanging(familyId); setTierChanging(familyId);
try { try {
const res = await fetch("/api/admin/families", { const res = await fetch("/api/admin/families", {
@ -113,13 +140,23 @@ export default function AdminFamilies() {
maxMembers: newTier === "pro" ? 10 : 2, maxMembers: newTier === "pro" ? 10 : 2,
}), }),
}); });
if (res.ok) fetchFamilies(); const data = await res.json();
} catch (err) { if (!res.ok) throw new Error(data.error || "Failed to update tier");
console.error("Failed to update tier:", err); showMessage(`Family moved to ${newTier}`, "success");
fetchFamilies();
} catch (err: any) {
showMessage(err.message, "error");
} }
setTierChanging(null); setTierChanging(null);
}; };
const updateMemberForm = (familyId: string, field: "email" | "role", value: string) => {
setMemberForms(prev => {
const current = prev[familyId] || { email: "", role: "caregiver" };
return { ...prev, [familyId]: { ...current, [field]: value } };
});
};
const filteredFamilies = families.filter((f) => { const filteredFamilies = families.filter((f) => {
const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase()); const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase());
const matchesTier = tierFilter === "all" || f.tier === tierFilter; const matchesTier = tierFilter === "all" || f.tier === tierFilter;
@ -143,10 +180,22 @@ export default function AdminFamilies() {
if (loading) return <div className="p-6 text-white">Loading...</div>; if (loading) return <div className="p-6 text-white">Loading...</div>;
const proCount = families.filter(f => f.tier === "pro").length; const proCount = families.filter(f => f.tier === "pro").length;
const freeCount = families.filter(f => f.tier === "free").length; const freeCount = families.filter(f => f.tier !== "pro").length;
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{error && (
<div className="bg-red-500/15 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm flex justify-between">
<span> {error}</span>
<button onClick={() => setError(null)} className="ml-4 opacity-60 hover:opacity-100"></button>
</div>
)}
{success && (
<div className="bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 px-4 py-3 rounded-xl text-sm">
{success}
</div>
)}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-2xl font-bold">Families</h1> <h1 className="text-2xl font-bold">Families</h1>
@ -155,16 +204,39 @@ export default function AdminFamilies() {
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={handleCreateFamily}>+ New Family</Button> <Button onClick={() => setShowNewFamily(!showNewFamily)}>+ New Family</Button>
<Button variant="secondary" onClick={exportCSV}>Export CSV</Button> <Button variant="secondary" onClick={exportCSV}>Export CSV</Button>
</div> </div>
</div> </div>
{/* New family inline form */}
{showNewFamily && (
<div className="bg-gray-800 border border-gray-700 p-4 rounded-xl flex gap-2 items-end">
<div className="flex-1">
<label className="text-xs text-gray-400 block mb-1">Family Name *</label>
<Input
type="text"
placeholder="e.g. The Smith Family"
value={newFamilyName}
onChange={(e) => setNewFamilyName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCreateFamily(); }}
autoFocus
/>
</div>
<Button onClick={handleCreateFamily} loading={creatingFamily} disabled={!newFamilyName.trim()}>
Create
</Button>
<Button variant="secondary" onClick={() => { setShowNewFamily(false); setNewFamilyName(""); }}>
Cancel
</Button>
</div>
)}
{/* Filters */} {/* Filters */}
<div className="flex gap-3"> <div className="flex gap-3">
<Input <Input
type="text" type="text"
placeholder="Search families..." placeholder="Search families"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="flex-1" className="flex-1"
@ -191,7 +263,9 @@ export default function AdminFamilies() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-700"> <tbody className="divide-y divide-gray-700">
{filteredFamilies.map((family) => ( {filteredFamilies.map((family) => {
const memberForm = memberForms[family.id] || { email: "", role: "caregiver" };
return (
<> <>
<tr key={family.id} className="hover:bg-gray-750"> <tr key={family.id} className="hover:bg-gray-750">
<td className="px-4 py-3"> <td className="px-4 py-3">
@ -199,9 +273,7 @@ export default function AdminFamilies() {
<div className="text-xs text-gray-600 font-mono">{family.id.slice(0, 8)}</div> <div className="text-xs text-gray-600 font-mono">{family.id.slice(0, 8)}</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2">
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge> <Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Button <Button
@ -209,7 +281,7 @@ export default function AdminFamilies() {
size="sm" size="sm"
onClick={() => setShowMembers(showMembers === family.id ? null : family.id)} onClick={() => setShowMembers(showMembers === family.id ? null : family.id)}
> >
{family.userCount} users {showMembers === family.id ? "▲" : "▼"} {family.userCount} {showMembers === family.id ? "▲" : "▼"}
</Button> </Button>
</td> </td>
<td className="px-4 py-3 text-gray-300">{family.childCount}</td> <td className="px-4 py-3 text-gray-300">{family.childCount}</td>
@ -223,55 +295,68 @@ export default function AdminFamilies() {
<Button <Button
variant={family.tier === "pro" ? "secondary" : "primary"} variant={family.tier === "pro" ? "secondary" : "primary"}
size="sm" size="sm"
onClick={() => handleTierChange(family.id, family.tier === "pro" ? "free" : "pro")} onClick={() => handleTierChange(family.id, family.tier)}
disabled={tierChanging === family.id} loading={tierChanging === family.id}
disabled={!!tierChanging}
> >
{tierChanging === family.id {family.tier === "pro" ? "↓ Free" : "↑ Pro"}
? "…"
: family.tier === "pro"
? "↓ Free"
: "↑ Pro"}
</Button> </Button>
</td> </td>
</tr> </tr>
{showMembers === family.id && ( {showMembers === family.id && (
<tr key={`${family.id}-members`}> <tr key={`${family.id}-members`}>
<td colSpan={7} className="bg-gray-900 px-6 py-4"> <td colSpan={7} className="bg-gray-900 px-6 py-4">
<div className="space-y-2"> <div className="space-y-3">
<div className="text-xs text-gray-500 font-semibold uppercase mb-2">Members</div> <div className="text-xs text-gray-500 font-semibold uppercase">Members</div>
{/* Add member form */}
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Input <Input
type="email" type="email"
placeholder="Add member by email" placeholder="Email address"
onChange={(e) => value={memberForm.email}
setAddMember({ familyId: family.id, email: e.target.value, role: "caregiver", name: "" }) onChange={(e) => updateMemberForm(family.id, "email", e.target.value)}
}
className="flex-1 max-w-xs" className="flex-1 max-w-xs"
onKeyDown={(e) => { if (e.key === "Enter") handleAddMember(family.id); }}
/> />
<Select <Select
onChange={(e) => value={memberForm.role}
setAddMember(addMember ? { ...addMember, role: e.target.value } : null) onChange={(e) => updateMemberForm(family.id, "role", e.target.value)}
}
> >
<option value="caregiver">Caregiver</option> <option value="caregiver">Caregiver</option>
<option value="owner">Owner</option> <option value="owner">Owner</option>
</Select> </Select>
<Button size="sm" onClick={handleAddMember}>Add</Button> <Button
size="sm"
onClick={() => handleAddMember(family.id)}
disabled={!memberForm.email}
>
Add
</Button>
</div> </div>
<div className="space-y-1 mt-2">
{/* Member list */}
<div className="space-y-1">
{(family.members || []).map((m) => ( {(family.members || []).map((m) => (
<div key={m.id} className="flex justify-between items-center bg-gray-800 px-3 py-2 rounded-lg"> <div key={m.id} className="flex justify-between items-center bg-gray-800 px-3 py-2 rounded-lg">
<div> <div>
<span className="text-sm">{m.email}</span> <span className="text-sm">{m.email}</span>
<span className="ml-2 text-xs text-gray-500 bg-gray-700 px-1.5 py-0.5 rounded">{m.role}</span> <span className="ml-2 text-xs text-gray-500 bg-gray-700 px-1.5 py-0.5 rounded">{m.role}</span>
</div> </div>
<Button variant="danger" size="sm" onClick={() => handleRemoveMember(m.id)}> <Button
variant="danger"
size="sm"
loading={removingMemberId === m.id}
disabled={!!removingMemberId}
onClick={() => handleRemoveMember(m.id)}
>
Remove Remove
</Button> </Button>
</div> </div>
))} ))}
{(family.members || []).length === 0 && ( {(family.members || []).length === 0 && (
<div className="text-gray-600 text-sm">No members yet</div> <div className="text-gray-600 text-sm py-1">No members yet</div>
)} )}
</div> </div>
</div> </div>
@ -279,7 +364,8 @@ export default function AdminFamilies() {
</tr> </tr>
)} )}
</> </>
))} );
})}
</tbody> </tbody>
</table> </table>
{filteredFamilies.length === 0 && ( {filteredFamilies.length === 0 && (

View file

@ -28,19 +28,28 @@ export default function AdminUsers() {
const [showPassword, setShowPassword] = useState<string | null>(null); const [showPassword, setShowPassword] = useState<string | null>(null);
const [passwordValue, setPasswordValue] = useState(""); const [passwordValue, setPasswordValue] = useState("");
const [newUser, setNewUser] = useState({ email: "", name: "", familyId: "", role: "caregiver" }); const [newUser, setNewUser] = useState({ email: "", name: "", familyId: "", role: "caregiver" });
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchUsers(); fetchUsers();
fetchFamilies(); fetchFamilies();
}, []); }, []);
const showMessage = (msg: string, type: "success" | "error") => {
if (type === "success") { setSuccess(msg); setTimeout(() => setSuccess(null), 3000); }
else { setError(msg); setTimeout(() => setError(null), 5000); }
};
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
const res = await fetch("/api/admin/users", { credentials: "include" }); const res = await fetch("/api/admin/users", { credentials: "include" });
const data = await res.json(); const data = await res.json();
if (data.error) throw new Error(data.error);
setUsers(data.users || []); setUsers(data.users || []);
} catch (err) { } catch (err: any) {
console.error("Failed to fetch users:", err); showMessage(err.message || "Failed to load users", "error");
} }
setLoading(false); setLoading(false);
}; };
@ -50,9 +59,7 @@ export default function AdminUsers() {
const res = await fetch("/api/admin/families", { credentials: "include" }); const res = await fetch("/api/admin/families", { credentials: "include" });
const data = await res.json(); const data = await res.json();
setFamilies(data.families || []); setFamilies(data.families || []);
} catch (err) { } catch {}
console.error("Failed to fetch families:", err);
}
}; };
const handleAddUser = async () => { const handleAddUser = async () => {
@ -64,28 +71,33 @@ export default function AdminUsers() {
credentials: "include", credentials: "include",
body: JSON.stringify(newUser), body: JSON.stringify(newUser),
}); });
if (res.ok) { const data = await res.json();
fetchUsers(); if (!res.ok) throw new Error(data.error || "Failed to create user");
showMessage("User created", "success");
setShowAdd(false); setShowAdd(false);
setNewUser({ email: "", name: "", familyId: "", role: "caregiver" }); setNewUser({ email: "", name: "", familyId: "", role: "caregiver" });
} fetchUsers();
} catch (err) { } catch (err: any) {
console.error("Failed to create user:", err); showMessage(err.message, "error");
} }
}; };
const handleRemoveUser = async (userId: string, memberId?: string) => { const handleDeleteUser = async (userId: string) => {
if (!confirm("Delete this user?")) return; if (!window.confirm("Delete this user permanently? This cannot be undone.")) return;
setDeletingId(userId);
try { try {
const params = memberId ? `memberId=${memberId}` : `userId=${userId}`; const res = await fetch(`/api/admin/users?userId=${userId}`, {
const res = await fetch(`/api/admin/users?${params}`, {
method: "DELETE", method: "DELETE",
credentials: "include", credentials: "include",
}); });
if (res.ok) fetchUsers(); const data = await res.json();
} catch (err) { if (!res.ok) throw new Error(data.error || "Delete failed");
console.error("Failed to delete:", err); showMessage("User deleted", "success");
setUsers(prev => prev.filter(u => u.id !== userId));
} catch (err: any) {
showMessage(err.message, "error");
} }
setDeletingId(null);
}; };
const handleSetPassword = async (userId: string, password: string) => { const handleSetPassword = async (userId: string, password: string) => {
@ -97,13 +109,14 @@ export default function AdminUsers() {
credentials: "include", credentials: "include",
body: JSON.stringify({ userId, password }), body: JSON.stringify({ userId, password }),
}); });
if (res.ok) { const data = await res.json();
fetchUsers(); if (!res.ok) throw new Error(data.error || "Failed to set password");
showMessage("Password updated", "success");
setShowPassword(null); setShowPassword(null);
setPasswordValue(""); setPasswordValue("");
} fetchUsers();
} catch (err) { } catch (err: any) {
console.error("Failed to set password:", err); showMessage(err.message, "error");
} }
}; };
@ -124,12 +137,23 @@ export default function AdminUsers() {
a.click(); a.click();
}; };
if (loading) { if (loading) return <div className="p-6 text-white">Loading...</div>;
return <div className="p-6 text-white">Loading...</div>;
}
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{/* Toast notifications */}
{error && (
<div className="bg-red-500/15 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm flex justify-between items-center">
<span> {error}</span>
<button onClick={() => setError(null)} className="ml-4 opacity-60 hover:opacity-100"></button>
</div>
)}
{success && (
<div className="bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 px-4 py-3 rounded-xl text-sm">
{success}
</div>
)}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-2xl font-bold">Users</h1> <h1 className="text-2xl font-bold">Users</h1>
@ -142,36 +166,47 @@ export default function AdminUsers() {
</div> </div>
{showAdd && ( {showAdd && (
<div className="bg-gray-800 p-4 rounded-lg flex gap-2 flex-wrap"> <div className="bg-gray-800 p-4 rounded-xl flex gap-2 flex-wrap items-end border border-gray-700">
<div className="flex-1 min-w-[200px]">
<label className="text-xs text-gray-400 block mb-1">Email *</label>
<Input <Input
type="email" type="email"
placeholder="Email" placeholder="user@example.com"
value={newUser.email} value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
className="flex-1 min-w-[200px]"
/> />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Name</label>
<Input <Input
type="text" type="text"
placeholder="Name (optional)" placeholder="Display name"
value={newUser.name} value={newUser.name}
onChange={(e) => setNewUser({ ...newUser, name: e.target.value })} onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
/> />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Family</label>
<Select <Select
value={newUser.familyId} value={newUser.familyId}
onChange={(e) => setNewUser({ ...newUser, familyId: e.target.value })} onChange={(e) => setNewUser({ ...newUser, familyId: e.target.value })}
> >
<option value="">Select Family</option> <option value="">No family</option>
{families.map((f) => ( {families.map((f) => (
<option key={f.id} value={f.id}>{f.name}</option> <option key={f.id} value={f.id}>{f.name}</option>
))} ))}
</Select> </Select>
</div>
<div className="flex gap-2">
<Button onClick={handleAddUser}>Create</Button> <Button onClick={handleAddUser}>Create</Button>
<Button variant="secondary" onClick={() => setShowAdd(false)}>Cancel</Button>
</div>
</div> </div>
)} )}
<Input <Input
type="text" type="text"
placeholder="Search users..." placeholder="Search by name or email…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
@ -194,7 +229,7 @@ export default function AdminUsers() {
<div className="font-medium">{user.name || user.email}</div> <div className="font-medium">{user.name || user.email}</div>
<div className="text-xs text-gray-500">{user.email}</div> <div className="text-xs text-gray-500">{user.email}</div>
</td> </td>
<td className="px-4 py-3">{user.familyName || "-"}</td> <td className="px-4 py-3 text-gray-300">{user.familyName || <span className="text-gray-600"></span>}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button <button
onClick={() => setShowPassword(user.id)} onClick={() => setShowPassword(user.id)}
@ -207,7 +242,13 @@ export default function AdminUsers() {
{user.createdAt?.slice(0, 10)} {user.createdAt?.slice(0, 10)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Button variant="danger" size="sm" onClick={() => handleRemoveUser(user.id, user.memberId)}> <Button
variant="danger"
size="sm"
loading={deletingId === user.id}
disabled={!!deletingId}
onClick={() => handleDeleteUser(user.id)}
>
Delete Delete
</Button> </Button>
</td> </td>
@ -228,22 +269,18 @@ export default function AdminUsers() {
<div className="space-y-4"> <div className="space-y-4">
<Input <Input
type="password" type="password"
placeholder="Enter password" placeholder="Enter new password"
value={passwordValue} value={passwordValue}
onChange={(e) => setPasswordValue(e.target.value)} onChange={(e) => setPasswordValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && showPassword) handleSetPassword(showPassword, passwordValue);
}}
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button fullWidth onClick={() => showPassword && handleSetPassword(showPassword, passwordValue)}>
fullWidth Set Password
onClick={() => showPassword && handleSetPassword(showPassword, passwordValue)}
>
Set
</Button> </Button>
<Button <Button variant="secondary" fullWidth onClick={() => { setShowPassword(null); setPasswordValue(""); }}>
variant="secondary"
fullWidth
onClick={() => { setShowPassword(null); setPasswordValue(""); }}
>
Cancel Cancel
</Button> </Button>
</div> </div>

View file

@ -116,13 +116,12 @@ export async function PATCH(request: Request) {
} }
} }
// Remove user from family // Remove user from family or delete entirely
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
const auth = await requireAdmin(request); const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const memberId = searchParams.get("memberId"); const memberId = searchParams.get("memberId");
const userId = searchParams.get("userId"); const userId = searchParams.get("userId");
@ -130,9 +129,15 @@ export async function DELETE(request: Request) {
if (memberId) { if (memberId) {
await sql`DELETE FROM family_members WHERE id = ${memberId}`; await sql`DELETE FROM family_members WHERE id = ${memberId}`;
} else if (userId) { } else if (userId) {
// Delete user entirely (careful!) // Must clean up FK-constrained tables before deleting user
// (all have ON DELETE no action constraints)
await sql`DELETE FROM sessions WHERE user_id = ${userId}`;
await sql`DELETE FROM accounts WHERE user_id = ${userId}`;
await sql`UPDATE support_tickets SET user_id = NULL WHERE user_id = ${userId}`;
await sql`DELETE FROM family_members WHERE user_id = ${userId}`; await sql`DELETE FROM family_members WHERE user_id = ${userId}`;
await sql`DELETE FROM users WHERE id = ${userId}`; await sql`DELETE FROM users WHERE id = ${userId}`;
} else {
return NextResponse.json({ error: "memberId or userId required" }, { status: 400 });
} }
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });