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 [tierFilter, setTierFilter] = useState("all");
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 [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(() => {
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 () => {
try {
const res = await fetch("/api/admin/families", { credentials: "include" });
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 || []);
} catch (err) {
console.error("Failed to fetch families:", err);
} catch (err: any) {
showMessage(err.message || "Failed to load families", "error");
}
setLoading(false);
};
const handleCreateFamily = async () => {
const name = prompt("Family name:");
if (!name) return;
if (!newFamilyName.trim()) return;
setCreatingFamily(true);
try {
const res = await fetch("/api/admin/families", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name }),
body: JSON.stringify({ name: newFamilyName.trim() }),
});
if (res.ok) fetchFamilies();
} catch (err) {
console.error("Failed to create:", err);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to create family");
showMessage(`Family "${newFamilyName.trim()}" created`, "success");
setNewFamilyName("");
setShowNewFamily(false);
fetchFamilies();
} catch (err: any) {
showMessage(err.message, "error");
}
setCreatingFamily(false);
};
const handleAddMember = async () => {
if (!addMember?.email || !addMember?.familyId) return;
const handleAddMember = async (familyId: string) => {
const form = memberForms[familyId];
if (!form?.email) return;
try {
const res = await fetch("/api/admin/families", {
method: "POST",
headers: { "Content-Type": "application/json" },
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();
setAddMember(null);
}
} catch (err) {
console.error("Failed to add member:", err);
} catch (err: any) {
showMessage(err.message, "error");
}
};
const handleRemoveMember = async (memberId: string) => {
if (!confirm("Remove this member?")) return;
if (!window.confirm("Remove this member from the family?")) return;
setRemovingMemberId(memberId);
try {
const res = await fetch(`/api/admin/families?memberId=${memberId}`, {
method: "DELETE",
credentials: "include",
});
if (res.ok) fetchFamilies();
} catch (err) {
console.error("Failed to remove:", err);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to remove member");
showMessage("Member removed", "success");
fetchFamilies();
} catch (err: any) {
showMessage(err.message, "error");
}
setRemovingMemberId(null);
};
const handleTierChange = async (familyId: string, newTier: string) => {
const family = families.find(f => f.id === familyId);
if (!family) return;
const handleTierChange = async (familyId: string, currentTier: string) => {
const newTier = currentTier === "pro" ? "free" : "pro";
const label = newTier === "pro" ? "Upgrade to Pro?" : "Downgrade to Free?";
if (!confirm(label)) return;
if (!window.confirm(label)) return;
setTierChanging(familyId);
try {
const res = await fetch("/api/admin/families", {
@ -113,13 +140,23 @@ export default function AdminFamilies() {
maxMembers: newTier === "pro" ? 10 : 2,
}),
});
if (res.ok) fetchFamilies();
} catch (err) {
console.error("Failed to update tier:", err);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to update tier");
showMessage(`Family moved to ${newTier}`, "success");
fetchFamilies();
} catch (err: any) {
showMessage(err.message, "error");
}
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 matchesSearch = f.name.toLowerCase().includes(search.toLowerCase());
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>;
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 (
<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>
<h1 className="text-2xl font-bold">Families</h1>
@ -155,16 +204,39 @@ export default function AdminFamilies() {
</p>
</div>
<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>
</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 */}
<div className="flex gap-3">
<Input
type="text"
placeholder="Search families..."
placeholder="Search families"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1"
@ -191,7 +263,9 @@ export default function AdminFamilies() {
</tr>
</thead>
<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">
<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>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
</div>
</td>
<td className="px-4 py-3">
<Button
@ -209,7 +281,7 @@ export default function AdminFamilies() {
size="sm"
onClick={() => setShowMembers(showMembers === family.id ? null : family.id)}
>
{family.userCount} users {showMembers === family.id ? "▲" : "▼"}
{family.userCount} {showMembers === family.id ? "▲" : "▼"}
</Button>
</td>
<td className="px-4 py-3 text-gray-300">{family.childCount}</td>
@ -223,55 +295,68 @@ export default function AdminFamilies() {
<Button
variant={family.tier === "pro" ? "secondary" : "primary"}
size="sm"
onClick={() => handleTierChange(family.id, family.tier === "pro" ? "free" : "pro")}
disabled={tierChanging === family.id}
onClick={() => handleTierChange(family.id, family.tier)}
loading={tierChanging === family.id}
disabled={!!tierChanging}
>
{tierChanging === family.id
? "…"
: family.tier === "pro"
? "↓ Free"
: "↑ Pro"}
{family.tier === "pro" ? "↓ Free" : "↑ Pro"}
</Button>
</td>
</tr>
{showMembers === family.id && (
<tr key={`${family.id}-members`}>
<td colSpan={7} className="bg-gray-900 px-6 py-4">
<div className="space-y-2">
<div className="text-xs text-gray-500 font-semibold uppercase mb-2">Members</div>
<div className="space-y-3">
<div className="text-xs text-gray-500 font-semibold uppercase">Members</div>
{/* Add member form */}
<div className="flex gap-2 items-center">
<Input
type="email"
placeholder="Add member by email"
onChange={(e) =>
setAddMember({ familyId: family.id, email: e.target.value, role: "caregiver", name: "" })
}
placeholder="Email address"
value={memberForm.email}
onChange={(e) => updateMemberForm(family.id, "email", e.target.value)}
className="flex-1 max-w-xs"
onKeyDown={(e) => { if (e.key === "Enter") handleAddMember(family.id); }}
/>
<Select
onChange={(e) =>
setAddMember(addMember ? { ...addMember, role: e.target.value } : null)
}
value={memberForm.role}
onChange={(e) => updateMemberForm(family.id, "role", e.target.value)}
>
<option value="caregiver">Caregiver</option>
<option value="owner">Owner</option>
</Select>
<Button size="sm" onClick={handleAddMember}>Add</Button>
<Button
size="sm"
onClick={() => handleAddMember(family.id)}
disabled={!memberForm.email}
>
Add
</Button>
</div>
<div className="space-y-1 mt-2">
{/* Member list */}
<div className="space-y-1">
{(family.members || []).map((m) => (
<div key={m.id} className="flex justify-between items-center bg-gray-800 px-3 py-2 rounded-lg">
<div>
<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>
</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
</Button>
</div>
))}
{(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>
@ -279,7 +364,8 @@ export default function AdminFamilies() {
</tr>
)}
</>
))}
);
})}
</tbody>
</table>
{filteredFamilies.length === 0 && (

View file

@ -28,19 +28,28 @@ export default function AdminUsers() {
const [showPassword, setShowPassword] = useState<string | null>(null);
const [passwordValue, setPasswordValue] = useState("");
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(() => {
fetchUsers();
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 () => {
try {
const res = await fetch("/api/admin/users", { credentials: "include" });
const data = await res.json();
if (data.error) throw new Error(data.error);
setUsers(data.users || []);
} catch (err) {
console.error("Failed to fetch users:", err);
} catch (err: any) {
showMessage(err.message || "Failed to load users", "error");
}
setLoading(false);
};
@ -50,9 +59,7 @@ export default function AdminUsers() {
const res = await fetch("/api/admin/families", { credentials: "include" });
const data = await res.json();
setFamilies(data.families || []);
} catch (err) {
console.error("Failed to fetch families:", err);
}
} catch {}
};
const handleAddUser = async () => {
@ -64,28 +71,33 @@ export default function AdminUsers() {
credentials: "include",
body: JSON.stringify(newUser),
});
if (res.ok) {
fetchUsers();
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to create user");
showMessage("User created", "success");
setShowAdd(false);
setNewUser({ email: "", name: "", familyId: "", role: "caregiver" });
}
} catch (err) {
console.error("Failed to create user:", err);
fetchUsers();
} catch (err: any) {
showMessage(err.message, "error");
}
};
const handleRemoveUser = async (userId: string, memberId?: string) => {
if (!confirm("Delete this user?")) return;
const handleDeleteUser = async (userId: string) => {
if (!window.confirm("Delete this user permanently? This cannot be undone.")) return;
setDeletingId(userId);
try {
const params = memberId ? `memberId=${memberId}` : `userId=${userId}`;
const res = await fetch(`/api/admin/users?${params}`, {
const res = await fetch(`/api/admin/users?userId=${userId}`, {
method: "DELETE",
credentials: "include",
});
if (res.ok) fetchUsers();
} catch (err) {
console.error("Failed to delete:", err);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Delete failed");
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) => {
@ -97,13 +109,14 @@ export default function AdminUsers() {
credentials: "include",
body: JSON.stringify({ userId, password }),
});
if (res.ok) {
fetchUsers();
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to set password");
showMessage("Password updated", "success");
setShowPassword(null);
setPasswordValue("");
}
} catch (err) {
console.error("Failed to set password:", err);
fetchUsers();
} catch (err: any) {
showMessage(err.message, "error");
}
};
@ -124,12 +137,23 @@ export default function AdminUsers() {
a.click();
};
if (loading) {
return <div className="p-6 text-white">Loading...</div>;
}
if (loading) return <div className="p-6 text-white">Loading...</div>;
return (
<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>
<h1 className="text-2xl font-bold">Users</h1>
@ -142,36 +166,47 @@ export default function AdminUsers() {
</div>
{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
type="email"
placeholder="Email"
placeholder="user@example.com"
value={newUser.email}
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
type="text"
placeholder="Name (optional)"
placeholder="Display name"
value={newUser.name}
onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Family</label>
<Select
value={newUser.familyId}
onChange={(e) => setNewUser({ ...newUser, familyId: e.target.value })}
>
<option value="">Select Family</option>
<option value="">No family</option>
{families.map((f) => (
<option key={f.id} value={f.id}>{f.name}</option>
))}
</Select>
</div>
<div className="flex gap-2">
<Button onClick={handleAddUser}>Create</Button>
<Button variant="secondary" onClick={() => setShowAdd(false)}>Cancel</Button>
</div>
</div>
)}
<Input
type="text"
placeholder="Search users..."
placeholder="Search by name or email…"
value={search}
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="text-xs text-gray-500">{user.email}</div>
</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">
<button
onClick={() => setShowPassword(user.id)}
@ -207,7 +242,13 @@ export default function AdminUsers() {
{user.createdAt?.slice(0, 10)}
</td>
<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
</Button>
</td>
@ -228,22 +269,18 @@ export default function AdminUsers() {
<div className="space-y-4">
<Input
type="password"
placeholder="Enter password"
placeholder="Enter new password"
value={passwordValue}
onChange={(e) => setPasswordValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && showPassword) handleSetPassword(showPassword, passwordValue);
}}
/>
<div className="flex gap-2">
<Button
fullWidth
onClick={() => showPassword && handleSetPassword(showPassword, passwordValue)}
>
Set
<Button fullWidth onClick={() => showPassword && handleSetPassword(showPassword, passwordValue)}>
Set Password
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => { setShowPassword(null); setPasswordValue(""); }}
>
<Button variant="secondary" fullWidth onClick={() => { setShowPassword(null); setPasswordValue(""); }}>
Cancel
</Button>
</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) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
const { searchParams } = new URL(request.url);
const memberId = searchParams.get("memberId");
const userId = searchParams.get("userId");
@ -130,9 +129,15 @@ export async function DELETE(request: Request) {
if (memberId) {
await sql`DELETE FROM family_members WHERE id = ${memberId}`;
} 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 users WHERE id = ${userId}`;
} else {
return NextResponse.json({ error: "memberId or userId required" }, { status: 400 });
}
return NextResponse.json({ success: true });