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 <noreply@anthropic.com>
169 lines
No EOL
5.7 KiB
TypeScript
169 lines
No EOL
5.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
interface Ticket {
|
|
id: string;
|
|
email: string;
|
|
subject: string;
|
|
description: string;
|
|
status: string;
|
|
priority: string;
|
|
createdAt: string;
|
|
familyName: string;
|
|
}
|
|
|
|
export default function AdminSupport() {
|
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [statusFilter, setStatusFilter] = useState("all");
|
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
|
const [replyMessage, setReplyMessage] = useState("");
|
|
|
|
useEffect(() => {
|
|
fetchTickets();
|
|
}, [statusFilter]);
|
|
|
|
const fetchTickets = async () => {
|
|
try {
|
|
const res = await fetch(`/api/admin/support?status=${statusFilter}`, { credentials: "include" });
|
|
const data = await res.json();
|
|
setTickets(data.tickets || []);
|
|
} catch (err) {
|
|
console.error("Failed to fetch tickets:", err);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
const updateStatus = async (ticketId: string, status: string) => {
|
|
try {
|
|
await fetch(`/api/admin/support`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ ticketId, status }),
|
|
});
|
|
fetchTickets();
|
|
} catch (err) {
|
|
console.error("Failed to update ticket:", err);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return <div className="p-6 text-white">Loading...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Support</h1>
|
|
<p className="text-gray-400">{tickets.length} tickets</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex gap-2">
|
|
{["all", "open", "in_progress", "resolved", "closed"].map((status) => (
|
|
<button
|
|
key={status}
|
|
onClick={() => setStatusFilter(status)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm ${
|
|
statusFilter === status ? "bg-rose-500" : "bg-gray-800"
|
|
}`}
|
|
>
|
|
{status.replace("_", " ")}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tickets List */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
{tickets.map((ticket) => (
|
|
<div
|
|
key={ticket.id}
|
|
onClick={() => setSelectedTicket(ticket)}
|
|
className={`p-4 rounded-xl cursor-pointer ${
|
|
selectedTicket?.id === ticket.id ? "bg-rose-500/20 border border-rose-500" : "bg-gray-800"
|
|
}`}
|
|
>
|
|
<div className="flex justify-between items-start mb-2">
|
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
ticket.priority === "urgent" ? "bg-red-900 text-red-400" :
|
|
ticket.priority === "high" ? "bg-orange-900 text-orange-400" :
|
|
"bg-gray-700"
|
|
}`}>
|
|
{ticket.priority}
|
|
</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
ticket.status === "open" ? "bg-emerald-900 text-emerald-400" :
|
|
ticket.status === "in_progress" ? "bg-amber-900 text-amber-400" :
|
|
"bg-gray-700"
|
|
}`}>
|
|
{ticket.status.replace("_", " ")}
|
|
</span>
|
|
</div>
|
|
<div className="font-medium">{ticket.subject}</div>
|
|
<div className="text-sm text-gray-400">{ticket.email}</div>
|
|
<div className="text-xs text-gray-500 mt-2">
|
|
{ticket.createdAt?.slice(0, 10)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{tickets.length === 0 && (
|
|
<div className="p-8 text-center text-gray-500">No tickets found</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Ticket Detail */}
|
|
<div className="bg-gray-800 rounded-xl p-4">
|
|
{selectedTicket ? (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<div className="font-medium text-lg">{selectedTicket.subject}</div>
|
|
<div className="text-sm text-gray-400">{selectedTicket.email}</div>
|
|
<div className="text-xs text-gray-500">
|
|
{selectedTicket.createdAt?.slice(0, 10)} · {selectedTicket.familyName}
|
|
</div>
|
|
</div>
|
|
<div className="p-3 bg-gray-700 rounded-lg">
|
|
{selectedTicket.description}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{selectedTicket.status === "open" && (
|
|
<button
|
|
onClick={() => updateStatus(selectedTicket.id, "in_progress")}
|
|
className="px-3 py-1.5 bg-amber-600 rounded-lg text-sm"
|
|
>
|
|
Start
|
|
</button>
|
|
)}
|
|
{selectedTicket.status === "in_progress" && (
|
|
<button
|
|
onClick={() => updateStatus(selectedTicket.id, "resolved")}
|
|
className="px-3 py-1.5 bg-emerald-600 rounded-lg text-sm"
|
|
>
|
|
Resolve
|
|
</button>
|
|
)}
|
|
{selectedTicket.status === "resolved" && (
|
|
<button
|
|
onClick={() => updateStatus(selectedTicket.id, "closed")}
|
|
className="px-3 py-1.5 bg-gray-600 rounded-lg text-sm"
|
|
>
|
|
Close
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-gray-500">
|
|
Select a ticket to view details
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |