Fix admin panels
- Families: add "New Family" button - Users: add "Add User" form with family selector - Add delete user option - Include member_id for proper removal Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
8867e66928
commit
2de47056e7
4 changed files with 222 additions and 13 deletions
|
|
@ -47,6 +47,9 @@ export default function AdminFamilies() {
|
||||||
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
|
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
console.error("API error:", data.error);
|
||||||
|
}
|
||||||
setFamilies(data.families || []);
|
setFamilies(data.families || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch families:", err);
|
console.error("Failed to fetch families:", err);
|
||||||
|
|
@ -54,6 +57,24 @@ export default function AdminFamilies() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateFamily = async () => {
|
||||||
|
const name = prompt("Family name:");
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/families", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("admin_token")}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (res.ok) fetchFamilies();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddMember = async () => {
|
const handleAddMember = async () => {
|
||||||
if (!addMember?.email || !addMember?.familyId) return;
|
if (!addMember?.email || !addMember?.familyId) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -126,6 +147,13 @@ export default function AdminFamilies() {
|
||||||
<h1 className="text-2xl font-bold">Families</h1>
|
<h1 className="text-2xl font-bold">Families</h1>
|
||||||
<p className="text-gray-400">{families.length} total families</p>
|
<p className="text-gray-400">{families.length} total families</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCreateFamily}
|
||||||
|
className="px-4 py-2 bg-rose-500 hover:bg-rose-600 rounded-lg"
|
||||||
|
>
|
||||||
|
+ New Family
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={exportCSV}
|
onClick={exportCSV}
|
||||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 rounded-lg"
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 rounded-lg"
|
||||||
|
|
@ -133,6 +161,7 @@ export default function AdminFamilies() {
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,22 @@ interface User {
|
||||||
familyId: string;
|
familyId: string;
|
||||||
familyName: string;
|
familyName: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
memberId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Family {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminUsers() {
|
export default function AdminUsers() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [families, setFamilies] = useState<Family[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [newUser, setNewUser] = useState({ email: "", name: "", familyId: "", role: "caregiver" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("admin_token");
|
const token = localStorage.getItem("admin_token");
|
||||||
|
|
@ -25,6 +34,7 @@ export default function AdminUsers() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
fetchFamilies();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
|
|
@ -40,9 +50,56 @@ export default function AdminUsers() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchFamilies = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/families", {
|
||||||
|
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setFamilies(data.families || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch families:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUser = async () => {
|
||||||
|
if (!newUser.email) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("admin_token")}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(newUser),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchUsers();
|
||||||
|
setShowAdd(false);
|
||||||
|
setNewUser({ email: "", name: "", familyId: "", role: "caregiver" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create user:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveUser = async (userId: string, memberId?: string) => {
|
||||||
|
if (!confirm("Delete this user?")) return;
|
||||||
|
try {
|
||||||
|
const params = memberId ? `memberId=${memberId}` : `userId=${userId}`;
|
||||||
|
const res = await fetch(`/api/admin/users?${params}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
|
||||||
|
});
|
||||||
|
if (res.ok) fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredUsers = users.filter((u) =>
|
const filteredUsers = users.filter((u) =>
|
||||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
u.name.toLowerCase().includes(search.toLowerCase())
|
(u.name || "").toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
const exportCSV = () => {
|
const exportCSV = () => {
|
||||||
|
|
@ -68,11 +125,41 @@ export default function AdminUsers() {
|
||||||
<h1 className="text-2xl font-bold">Users</h1>
|
<h1 className="text-2xl font-bold">Users</h1>
|
||||||
<p className="text-gray-400">{users.length} total users</p>
|
<p className="text-gray-400">{users.length} total users</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={exportCSV} className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 rounded-lg">
|
<button onClick={() => setShowAdd(!showAdd)} className="px-4 py-2 bg-rose-500 hover:bg-rose-600 rounded-lg">
|
||||||
Export CSV
|
+ Add User
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<div className="bg-gray-800 p-4 rounded-lg flex gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={newUser.email}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
|
||||||
|
className="flex-1 bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white min-w-[200px]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name (optional)"
|
||||||
|
value={newUser.name}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
|
||||||
|
className="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={newUser.familyId}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, familyId: e.target.value })}
|
||||||
|
className="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value="">Select Family</option>
|
||||||
|
{families.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>{f.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={handleAddUser} className="px-4 py-2 bg-rose-500 rounded text-white">Create</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search users..."
|
placeholder="Search users..."
|
||||||
|
|
@ -88,6 +175,7 @@ export default function AdminUsers() {
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">User</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">User</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Joined</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Joined</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700">
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
|
@ -97,10 +185,18 @@ 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">{user.familyName || "-"}</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-400">
|
<td className="px-4 py-3 text-sm text-gray-400">
|
||||||
{user.createdAt?.slice(0, 10)}
|
{user.createdAt?.slice(0, 10)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveUser(user.id, user.memberId)}
|
||||||
|
className="text-red-400 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export async function PATCH(request: Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add member to family
|
// Add member to family or create family
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const authHeader = request.headers.get("authorization");
|
const authHeader = request.headers.get("authorization");
|
||||||
|
|
@ -109,8 +109,19 @@ export async function POST(request: Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { familyId, email, role, displayName } = body;
|
const { familyId, email, role, displayName, name } = body;
|
||||||
|
|
||||||
|
// Create new family
|
||||||
|
if (name && !familyId) {
|
||||||
|
const newFamilyId = crypto.randomUUID();
|
||||||
|
await sql`
|
||||||
|
INSERT INTO families (id, name, tier, max_children, max_members, created_at, updated_at)
|
||||||
|
VALUES (${newFamilyId}, ${name}, 'free', 1, 2, NOW(), NOW())
|
||||||
|
`;
|
||||||
|
return NextResponse.json({ success: true, familyId: newFamilyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add member to existing family
|
||||||
if (!familyId || !email) {
|
if (!familyId || !email) {
|
||||||
return NextResponse.json({ error: "familyId and email required" }, { status: 400 });
|
return NextResponse.json({ error: "familyId and email required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
// GET all users
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const authHeader = request.headers.get("authorization");
|
const authHeader = request.headers.get("authorization");
|
||||||
|
|
@ -8,13 +9,14 @@ export async function GET(request: Request) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all users via family_members table with family info
|
// Get all users
|
||||||
const users = await sql`
|
const users = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
u.id,
|
u.id,
|
||||||
u.email,
|
u.email,
|
||||||
u.name,
|
u.name,
|
||||||
fm.family_id,
|
fm.family_id,
|
||||||
|
fm.id as member_id,
|
||||||
f.name as family_name,
|
f.name as family_name,
|
||||||
u.created_at
|
u.created_at
|
||||||
FROM users u
|
FROM users u
|
||||||
|
|
@ -29,6 +31,7 @@ export async function GET(request: Request) {
|
||||||
email: u.email,
|
email: u.email,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
familyId: u.family_id,
|
familyId: u.family_id,
|
||||||
|
memberId: u.member_id,
|
||||||
familyName: u.family_name,
|
familyName: u.family_name,
|
||||||
createdAt: u.created_at ? new Date(u.created_at).toISOString() : null,
|
createdAt: u.created_at ? new Date(u.created_at).toISOString() : null,
|
||||||
})),
|
})),
|
||||||
|
|
@ -38,3 +41,73 @@ export async function GET(request: Request) {
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create user or add to family
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const authHeader = request.headers.get("authorization");
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { email, familyId, role, name } = body;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.json({ error: "email required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
let existing = await sql`SELECT id FROM users WHERE email = ${email}`;
|
||||||
|
let userId = existing?.[0]?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
userId = crypto.randomUUID();
|
||||||
|
await sql`
|
||||||
|
INSERT INTO users (id, email, name, created_at, updated_at)
|
||||||
|
VALUES (${userId}, ${email}, ${name || email}, NOW(), NOW())
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to family if familyId provided
|
||||||
|
if (familyId) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO family_members (id, family_id, user_id, role, display_name, created_at)
|
||||||
|
VALUES (${crypto.randomUUID()}, ${familyId}, ${userId}, ${role || 'caregiver'}, ${name || email}, NOW())
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, userId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Admin users error:", error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove user from family
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const authHeader = request.headers.get("authorization");
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const memberId = searchParams.get("memberId");
|
||||||
|
const userId = searchParams.get("userId");
|
||||||
|
|
||||||
|
if (memberId) {
|
||||||
|
await sql`DELETE FROM family_members WHERE id = ${memberId}`;
|
||||||
|
} else if (userId) {
|
||||||
|
// Delete user entirely (careful!)
|
||||||
|
await sql`DELETE FROM family_members WHERE user_id = ${userId}`;
|
||||||
|
await sql`DELETE FROM users WHERE id = ${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Admin delete error:", error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue