Add admin member management
- View members per family - Add new member by email - Remove member from family - Simple password auth migration file Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f967215fc8
commit
da8675c045
2 changed files with 194 additions and 22 deletions
|
|
@ -3,6 +3,14 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Family {
|
interface Family {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -12,6 +20,7 @@ interface Family {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
|
members: Member[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminFamilies() {
|
export default function AdminFamilies() {
|
||||||
|
|
@ -20,6 +29,8 @@ export default function AdminFamilies() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
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 [addMember, setAddMember] = useState<{familyId: string; email: string; role: string; name: string} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("admin_token");
|
const token = localStorage.getItem("admin_token");
|
||||||
|
|
@ -43,6 +54,39 @@ export default function AdminFamilies() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddMember = async () => {
|
||||||
|
if (!addMember?.email || !addMember?.familyId) 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(addMember),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchFamilies();
|
||||||
|
setAddMember(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to add member:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMember = async (memberId: string) => {
|
||||||
|
if (!confirm("Remove this member?")) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/families?memberId=${memberId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
|
||||||
|
});
|
||||||
|
if (res.ok) fetchFamilies();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to remove:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -125,27 +169,66 @@ export default function AdminFamilies() {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700">
|
<tbody className="divide-y divide-gray-700">
|
||||||
{filteredFamilies.map((family) => (
|
{filteredFamilies.map((family) => (
|
||||||
<tr key={family.id} className="hover:bg-gray-750">
|
<>
|
||||||
<td className="px-4 py-3">
|
<tr key={family.id} className="hover:bg-gray-750">
|
||||||
<div className="font-medium">{family.name}</div>
|
<td className="px-4 py-3">
|
||||||
<div className="text-xs text-gray-500">{family.id}</div>
|
<div className="font-medium">{family.name}</div>
|
||||||
</td>
|
<div className="text-xs text-gray-500">{family.id}</div>
|
||||||
<td className="px-4 py-3">
|
</td>
|
||||||
<span className={`px-2 py-1 rounded text-xs ${
|
<td className="px-4 py-3">
|
||||||
family.tier === "pro" ? "bg-emerald-900 text-emerald-400" : "bg-gray-600"
|
<span className={`px-2 py-1 rounded text-xs ${
|
||||||
}`}>
|
family.tier === "pro" ? "bg-emerald-900 text-emerald-400" : "bg-gray-600"
|
||||||
{family.tier}
|
}`}>
|
||||||
</span>
|
{family.tier}
|
||||||
</td>
|
</span>
|
||||||
<td className="px-4 py-3">{family.userCount}</td>
|
</td>
|
||||||
<td className="px-4 py-3">{family.childCount}</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-4 py-3 text-sm text-gray-400">
|
<button
|
||||||
{family.maxChildren} kids, {family.maxMembers} members
|
onClick={() => setShowMembers(showMembers === family.id ? null : family.id)}
|
||||||
</td>
|
className="text-rose-400 underline"
|
||||||
<td className="px-4 py-3 text-sm text-gray-400">
|
>
|
||||||
{family.createdAt?.slice(0, 10)}
|
{family.userCount} users
|
||||||
</td>
|
</button>
|
||||||
</tr>
|
</td>
|
||||||
|
<td className="px-4 py-3">{family.childCount}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400">
|
||||||
|
{family.maxChildren} kids, {family.maxMembers} members
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400">
|
||||||
|
{family.createdAt?.slice(0, 10)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{showMembers === family.id && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="bg-gray-800 p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="New member email"
|
||||||
|
onChange={(e) => setAddMember({familyId: family.id, email: e.target.value, role: "caregiver", name: ""})}
|
||||||
|
className="flex-1 bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
onChange={(e) => setAddMember(addMember ? {...addMember, role: e.target.value} : null)}
|
||||||
|
className="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value="caregiver">Caregiver</option>
|
||||||
|
<option value="owner">Owner</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={handleAddMember} className="px-3 py-2 bg-rose-500 rounded text-white">Add</button>
|
||||||
|
</div>
|
||||||
|
{(family.members || []).map((m) => (
|
||||||
|
<div key={m.id} className="flex justify-between items-center bg-gray-700 p-2 rounded">
|
||||||
|
<span>{m.email} ({m.role})</span>
|
||||||
|
<button onClick={() => handleRemoveMember(m.id)} className="text-red-400 text-sm">Remove</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
// GET all families with members
|
||||||
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,7 +9,7 @@ export async function GET(request: Request) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all families with user and child counts using family_members table
|
// Get all families with user and child counts
|
||||||
const families = await sql`
|
const families = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
f.id,
|
f.id,
|
||||||
|
|
@ -26,6 +27,30 @@ export async function GET(request: Request) {
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Get members for each family
|
||||||
|
const familyIds = families.map((f: any) => f.id);
|
||||||
|
let members: any[] = [];
|
||||||
|
if (familyIds.length > 0) {
|
||||||
|
members = await sql`
|
||||||
|
SELECT fm.id, fm.family_id, fm.user_id, fm.role, fm.display_name, u.email
|
||||||
|
FROM family_members fm
|
||||||
|
JOIN users u ON u.id = fm.user_id
|
||||||
|
WHERE fm.family_id = ANY(${familyIds})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberMap = new Map();
|
||||||
|
(members || []).forEach((m: any) => {
|
||||||
|
if (!memberMap.has(m.family_id)) memberMap.set(m.family_id, []);
|
||||||
|
memberMap.get(m.family_id).push({
|
||||||
|
id: m.id,
|
||||||
|
userId: m.user_id,
|
||||||
|
email: m.email,
|
||||||
|
role: m.role,
|
||||||
|
displayName: m.display_name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
families: families.map((f: any) => ({
|
families: families.map((f: any) => ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
|
|
@ -36,6 +61,7 @@ export async function GET(request: Request) {
|
||||||
createdAt: f.created_at ? new Date(f.created_at).toISOString() : null,
|
createdAt: f.created_at ? new Date(f.created_at).toISOString() : null,
|
||||||
userCount: Number(f.user_count) || 0,
|
userCount: Number(f.user_count) || 0,
|
||||||
childCount: Number(f.child_count) || 0,
|
childCount: Number(f.child_count) || 0,
|
||||||
|
members: memberMap.get(f.id) || [],
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -69,6 +95,69 @@ export async function PATCH(request: Request) {
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Admin families error:", error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add member 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 { familyId, email, role, displayName } = body;
|
||||||
|
|
||||||
|
if (!familyId || !email) {
|
||||||
|
return NextResponse.json({ error: "familyId and email required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
let users = await sql`SELECT id FROM users WHERE email = ${email}`;
|
||||||
|
let userId = users?.[0]?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
userId = crypto.randomUUID();
|
||||||
|
await sql`INSERT INTO users (id, email, created_at, updated_at) VALUES (${userId}, ${email}, NOW(), NOW())`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to family_members
|
||||||
|
await sql`
|
||||||
|
INSERT INTO family_members (id, family_id, user_id, role, display_name, created_at)
|
||||||
|
VALUES (${crypto.randomUUID()}, ${familyId}, ${userId}, ${role || 'caregiver'}, ${displayName || email}, NOW())
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Admin add member error:", error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove member 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");
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
return NextResponse.json({ error: "memberId required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`DELETE FROM family_members WHERE id = ${memberId}`;
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Admin remove member error:", error);
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue