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:
parent
23010e9d90
commit
c2695435f2
3 changed files with 317 additions and 189 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue