From fc0e75b5add89fbc906a27703667ca43387a61e2 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sun, 17 May 2026 12:16:10 +0530 Subject: [PATCH] fix(admin): scope FamilyProvider out of admin routes, ensure cookies on admin fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes: - tia_admin_session is httpOnly so document.cookie could never read it → all client-side cookie checks always failed and redirected before any data fetched - Sub-pages used localStorage.getItem("admin_token") which was never stored, and passed Authorization: Bearer null headers the server ignores Fixes: - FamilyProvider: use usePathname() hook instead of window.location.pathname - admin/layout.tsx: rewrite as server component using verifyAdminSession() (new lib/admin-auth.ts helper that uses next/headers cookies()) → server-side redirect to /admin-login if session invalid; extract sidebar to AdminSidebar.tsx - admin/page.tsx: remove broken document.cookie guard (layout handles auth now) - admin-login/page.tsx: replace document.cookie check with GET /api/admin/auth call - All 7 admin sub-pages: remove localStorage guard, remove Authorization: Bearer headers, add credentials: include to every fetch call Co-Authored-By: Claude Sonnet 4.6 --- src/app/FamilyProvider.tsx | 10 +-- src/app/admin-login/page.tsx | 8 +-- src/app/admin/AdminSidebar.tsx | 81 +++++++++++++++++++++ src/app/admin/analytics/page.tsx | 11 +-- src/app/admin/children/page.tsx | 11 +-- src/app/admin/families/page.tsx | 25 ++----- src/app/admin/layout.tsx | 116 +++---------------------------- src/app/admin/page.tsx | 7 -- src/app/admin/revenue/page.tsx | 11 +-- src/app/admin/settings/page.tsx | 12 +--- src/app/admin/support/page.tsx | 17 +---- src/app/admin/users/page.tsx | 29 ++------ src/lib/admin-auth.ts | 28 ++++++++ 13 files changed, 145 insertions(+), 221 deletions(-) create mode 100644 src/app/admin/AdminSidebar.tsx diff --git a/src/app/FamilyProvider.tsx b/src/app/FamilyProvider.tsx index b7e565a..64bc7d9 100644 --- a/src/app/FamilyProvider.tsx +++ b/src/app/FamilyProvider.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, createContext, useContext } from "react"; import { ReactNode } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, usePathname } from "next/navigation"; interface Child { id: string; @@ -39,6 +39,7 @@ export function useFamily() { export function FamilyProvider({ children: providerChildren }: { children: ReactNode }) { const router = useRouter(); + const pathname = usePathname(); const [familyId, setFamilyId] = useState(null); const [familyName, setFamilyName] = useState(null); const [childId, setChildId] = useState(null); @@ -49,12 +50,7 @@ export function FamilyProvider({ children: providerChildren }: { children: React const [memberCount, setMemberCount] = useState(2); useEffect(() => { - if (typeof window === "undefined") { - setLoading(false); - return; - } - const path = window.location.pathname; - if (path.startsWith("/admin") || path === "/admin-login") { + if (pathname?.startsWith("/admin") || pathname === "/admin-login") { setLoading(false); return; } diff --git a/src/app/admin-login/page.tsx b/src/app/admin-login/page.tsx index 5b18b12..524c673 100644 --- a/src/app/admin-login/page.tsx +++ b/src/app/admin-login/page.tsx @@ -11,10 +11,10 @@ export default function AdminLoginPage() { const [loading, setLoading] = useState(false); useEffect(() => { - const token = document.cookie.match(/tia_admin_session=([^;]+)/)?.[1]; - if (token) { - router.push("/admin"); - } + fetch("/api/admin/auth", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => { if (d.authenticated) router.push("/admin"); }) + .catch(() => {}); }, [router]); const handleLogin = async (e: React.FormEvent) => { diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx new file mode 100644 index 0000000..d758e76 --- /dev/null +++ b/src/app/admin/AdminSidebar.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import Link from "next/link"; + +interface NavItem { + name: string; + href: string; + icon: string; +} + +const navItems: NavItem[] = [ + { name: "Dashboard", href: "/admin", icon: "📊" }, + { name: "Families", href: "/admin/families", icon: "🏠" }, + { name: "Users", href: "/admin/users", icon: "👥" }, + { name: "Children", href: "/admin/children", icon: "👶" }, + { name: "Revenue", href: "/admin/revenue", icon: "💰" }, + { name: "Analytics", href: "/admin/analytics", icon: "📈" }, + { name: "Support", href: "/admin/support", icon: "🎫" }, + { name: "Settings", href: "/admin/settings", icon: "⚙️" }, +]; + +export default function AdminSidebar({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const [sidebarOpen, setSidebarOpen] = useState(true); + + const handleLogout = async () => { + try { + await fetch("/api/admin/auth", { method: "DELETE", credentials: "include" }); + } catch {} + router.push("/admin-login"); + }; + + return ( +
+ + +
{children}
+
+ ); +} diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index 17fd765..806d8cf 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; interface EngagementStats { totalLogs: number; @@ -15,24 +14,16 @@ interface EngagementStats { } export default function AdminAnalytics() { - const router = useRouter(); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { - const token = localStorage.getItem("admin_token"); - if (!token) { - router.push("/admin/login"); - return; - } fetchAnalytics(); }, []); const fetchAnalytics = async () => { try { - const res = await fetch("/api/admin/analytics", { - headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }, - }); + const res = await fetch("/api/admin/analytics", { credentials: "include" }); const data = await res.json(); setStats(data); } catch (err) { diff --git a/src/app/admin/children/page.tsx b/src/app/admin/children/page.tsx index 655df1e..b403c3f 100644 --- a/src/app/admin/children/page.tsx +++ b/src/app/admin/children/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; interface Child { id: string; @@ -13,25 +12,17 @@ interface Child { } export default function AdminChildren() { - const router = useRouter(); const [children, setChildren] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); useEffect(() => { - const token = localStorage.getItem("admin_token"); - if (!token) { - router.push("/admin/login"); - return; - } fetchChildren(); }, []); const fetchChildren = async () => { try { - const res = await fetch("/api/admin/children", { - headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }, - }); + const res = await fetch("/api/admin/children", { credentials: "include" }); const data = await res.json(); setChildren(data.children || []); } catch (err) { diff --git a/src/app/admin/families/page.tsx b/src/app/admin/families/page.tsx index 80c93f5..f03146c 100644 --- a/src/app/admin/families/page.tsx +++ b/src/app/admin/families/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; interface Member { id: string; @@ -24,7 +23,6 @@ interface Family { } export default function AdminFamilies() { - const router = useRouter(); const [families, setFamilies] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); @@ -33,19 +31,12 @@ export default function AdminFamilies() { const [addMember, setAddMember] = useState<{familyId: string; email: string; role: string; name: string} | null>(null); useEffect(() => { - const token = localStorage.getItem("admin_token"); - if (!token) { - router.push("/admin/login"); - return; - } fetchFamilies(); }, []); const fetchFamilies = async () => { try { - const res = await fetch("/api/admin/families", { - headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }, - }); + const res = await fetch("/api/admin/families", { credentials: "include" }); const data = await res.json(); if (data.error) { console.error("API error:", data.error); @@ -63,10 +54,8 @@ export default function AdminFamilies() { try { const res = await fetch("/api/admin/families", { method: "POST", - headers: { - Authorization: `Bearer ${localStorage.getItem("admin_token")}`, - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ name }), }); if (res.ok) fetchFamilies(); @@ -80,10 +69,8 @@ export default function AdminFamilies() { try { const res = await fetch("/api/admin/families", { method: "POST", - headers: { - Authorization: `Bearer ${localStorage.getItem("admin_token")}`, - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify(addMember), }); if (res.ok) { @@ -100,7 +87,7 @@ export default function AdminFamilies() { try { const res = await fetch(`/api/admin/families?memberId=${memberId}`, { method: "DELETE", - headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }, + credentials: "include", }); if (res.ok) fetchFamilies(); } catch (err) { diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 7c75da9..51f7e50 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,110 +1,12 @@ -"use client"; +import { redirect } from "next/navigation"; +import { verifyAdminSession } from "@/lib/admin-auth"; +import AdminSidebar from "./AdminSidebar"; -import { useEffect, useState } from "react"; -import { useRouter, usePathname } from "next/navigation"; -import Link from "next/link"; - -interface NavItem { - name: string; - href: string; - icon: string; -} - -const navItems: NavItem[] = [ - { name: "Dashboard", href: "/admin", icon: "📊" }, - { name: "Families", href: "/admin/families", icon: "🏠" }, - { name: "Users", href: "/admin/users", icon: "👥" }, - { name: "Children", href: "/admin/children", icon: "👶" }, - { name: "Revenue", href: "/admin/revenue", icon: "💰" }, - { name: "Analytics", href: "/admin/analytics", icon: "📈" }, - { name: "Support", href: "/admin/support", icon: "🎫" }, - { name: "Settings", href: "/admin/settings", icon: "⚙️" }, -]; - -export default function AdminLayout({ children }: { children: React.ReactNode }) { - const router = useRouter(); - const pathname = usePathname(); - const [sidebarOpen, setSidebarOpen] = useState(true); - - // Check if this is the login page - don't show sidebar - const isLoginPage = pathname === "/admin-login"; - - useEffect(() => { - // Only check auth if not on login page - if (isLoginPage) return; - - const token = document.cookie.match(/tia_admin_session=([^;]+)/)?.[1]; - if (!token) { - router.push("/admin-login"); - return; - } - }, [router, isLoginPage]); - - const handleLogout = async () => { - try { - await fetch("/api/admin/auth", { method: "DELETE" }); - } catch (e) {} - router.push("/admin-login"); - }; - - // Login page - render without sidebar - if (isLoginPage) { - return ( -
- {children} -
- ); +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const auth = await verifyAdminSession(); + if (!auth.success) { + redirect("/admin-login"); } - // Main layout with sidebar - return ( -
- {/* Sidebar */} - - - {/* Main Content */} -
{children}
-
- ); -} \ No newline at end of file + return {children}; +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ee94c41..c07827b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; interface Stats { overview: { @@ -25,17 +24,11 @@ interface Stats { } export default function AdminDashboard() { - const router = useRouter(); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [period, setPeriod] = useState("30"); useEffect(() => { - const token = document.cookie.match(/tia_admin_session=([^;]+)/)?.[1]; - if (!token) { - router.push("/admin-login"); - return; - } fetchStats(); }, [period]); diff --git a/src/app/admin/revenue/page.tsx b/src/app/admin/revenue/page.tsx index 108ab8d..797037e 100644 --- a/src/app/admin/revenue/page.tsx +++ b/src/app/admin/revenue/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; interface RevenueData { proFamilies: number; @@ -13,24 +12,16 @@ interface RevenueData { const PRO_PRICE = 9.99; export default function AdminRevenue() { - const router = useRouter(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { - const token = localStorage.getItem("admin_token"); - if (!token) { - router.push("/admin/login"); - return; - } fetchRevenue(); }, []); const fetchRevenue = async () => { try { - const res = await fetch("/api/admin/stats", { - headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }, - }); + const res = await fetch("/api/admin/stats", { credentials: "include" }); const stats = await res.json(); setData({ proFamilies: stats.overview?.proFamilies || 0, diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 74fb0e9..7109b19 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -1,7 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useState } from "react"; interface Settings { proPrice: number; @@ -12,7 +11,6 @@ interface Settings { } export default function AdminSettings() { - const router = useRouter(); const [settings, setSettings] = useState({ proPrice: 9.99, freeMaxChildren: 1, @@ -22,14 +20,6 @@ export default function AdminSettings() { }); const [saved, setSaved] = useState(false); - useEffect(() => { - const token = localStorage.getItem("admin_token"); - if (!token) { - router.push("/admin/login"); - return; - } - }, [router]); - const handleSave = async () => { setSaved(true); setTimeout(() => setSaved(false), 2000); diff --git a/src/app/admin/support/page.tsx b/src/app/admin/support/page.tsx index a9080cd..5f49522 100644 --- a/src/app/admin/support/page.tsx +++ b/src/app/admin/support/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; interface Ticket { id: string; @@ -15,7 +14,6 @@ interface Ticket { } export default function AdminSupport() { - const router = useRouter(); const [tickets, setTickets] = useState([]); const [loading, setLoading] = useState(true); const [statusFilter, setStatusFilter] = useState("all"); @@ -23,19 +21,12 @@ export default function AdminSupport() { const [replyMessage, setReplyMessage] = useState(""); useEffect(() => { - const token = localStorage.getItem("admin_token"); - if (!token) { - router.push("/admin/login"); - return; - } fetchTickets(); }, [statusFilter]); const fetchTickets = async () => { try { - const res = await fetch(`/api/admin/support?status=${statusFilter}`, { - headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }, - }); + const res = await fetch(`/api/admin/support?status=${statusFilter}`, { credentials: "include" }); const data = await res.json(); setTickets(data.tickets || []); } catch (err) { @@ -48,10 +39,8 @@ export default function AdminSupport() { try { await fetch(`/api/admin/support`, { method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${localStorage.getItem("admin_token")}`, - }, + headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ ticketId, status }), }); fetchTickets(); diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index bfa0b78..aadf24c 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; interface User { id: string; @@ -20,7 +19,6 @@ interface Family { } export default function AdminUsers() { - const router = useRouter(); const [users, setUsers] = useState([]); const [families, setFamilies] = useState([]); const [loading, setLoading] = useState(true); @@ -30,20 +28,13 @@ export default function AdminUsers() { const [newUser, setNewUser] = useState({ email: "", name: "", familyId: "", role: "caregiver" }); useEffect(() => { - const token = localStorage.getItem("admin_token"); - if (!token) { - router.push("/admin/login"); - return; - } fetchUsers(); fetchFamilies(); }, []); const fetchUsers = async () => { try { - const res = await fetch("/api/admin/users", { - headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }, - }); + const res = await fetch("/api/admin/users", { credentials: "include" }); const data = await res.json(); setUsers(data.users || []); } catch (err) { @@ -54,9 +45,7 @@ export default function AdminUsers() { const fetchFamilies = async () => { try { - const res = await fetch("/api/admin/families", { - headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }, - }); + const res = await fetch("/api/admin/families", { credentials: "include" }); const data = await res.json(); setFamilies(data.families || []); } catch (err) { @@ -69,10 +58,8 @@ export default function AdminUsers() { try { const res = await fetch("/api/admin/users", { method: "POST", - headers: { - Authorization: `Bearer ${localStorage.getItem("admin_token")}`, - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify(newUser), }); if (res.ok) { @@ -91,7 +78,7 @@ export default function AdminUsers() { const params = memberId ? `memberId=${memberId}` : `userId=${userId}`; const res = await fetch(`/api/admin/users?${params}`, { method: "DELETE", - headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }, + credentials: "include", }); if (res.ok) fetchUsers(); } catch (err) { @@ -104,10 +91,8 @@ export default function AdminUsers() { try { const res = await fetch("/api/admin/users", { method: "PATCH", - headers: { - Authorization: `Bearer ${localStorage.getItem("admin_token")}`, - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ userId, password }), }); if (res.ok) { diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts index d089c1c..2a440ff 100644 --- a/src/lib/admin-auth.ts +++ b/src/lib/admin-auth.ts @@ -1,6 +1,34 @@ import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; import { sql } from "@/db"; +/** + * Verify admin session in server components using next/headers cookies. + */ +export async function verifyAdminSession(): Promise<{ + success: boolean; + admin?: { username: string; role: string }; +}> { + try { + const cookieStore = await cookies(); + const sessionToken = cookieStore.get("tia_admin_session")?.value; + if (!sessionToken) return { success: false }; + + const sessions = await sql.unsafe( + `SELECT username, role FROM admin_sessions + JOIN admins ON admins.id = admin_sessions.admin_id + WHERE session_token = $1 AND expires_at > NOW() + LIMIT 1`, + [sessionToken] + ); + if (!sessions || sessions.length === 0) return { success: false }; + const row = sessions[0] as unknown as { username: string; role: string }; + return { success: true, admin: { username: row.username, role: row.role } }; + } catch { + return { success: false }; + } +} + /** * Validate admin session from tia_admin_session cookie */