Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useState, useEffect } from "react"; | |
| import Link from "next/link"; | |
| import { | |
| LayoutDashboard, Users, ScanText, Key, Shield, ScrollText, Activity, | |
| Search, Ban, CircleCheck, ChevronDown, ChevronUp, Trash2, Crown, | |
| ArrowLeft, RefreshCw, TriangleAlert, UserCheck, Eye, EyeOff | |
| } from "lucide-react"; | |
| type Tab = "overview" | "users" | "scans" | "teams" | "api-keys" | "logs"; | |
| export default function AdminPage() { | |
| const [tab, setTab] = useState<Tab>("overview"); | |
| const [stats, setStats] = useState<any>(null); | |
| const [users, setUsers] = useState<any[]>([]); | |
| const [scans, setScans] = useState<any[]>([]); | |
| const [teams, setTeams] = useState<any[]>([]); | |
| const [apiKeys, setApiKeys] = useState<any[]>([]); | |
| const [logs, setLogs] = useState<any[]>([]); | |
| const [search, setSearch] = useState(""); | |
| const [loading, setLoading] = useState(false); | |
| const [userTotal, setUserTotal] = useState(0); | |
| const [scanTotal, setScanTotal] = useState(0); | |
| async function fetchData(action: string, params?: Record<string, string>) { | |
| const qs = new URLSearchParams({ action, ...params }); | |
| const res = await fetch(`/api/admin?${qs}`); | |
| if (!res.ok) { window.location.href = "/dashboard-pages/dashboard"; return null; } | |
| return res.json(); | |
| } | |
| async function adminAction(body: any) { | |
| setLoading(true); | |
| await fetch("/api/admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); | |
| setLoading(false); | |
| loadTab(tab); | |
| } | |
| async function loadTab(t: Tab) { | |
| setLoading(true); | |
| switch (t) { | |
| case "overview": { const d = await fetchData("stats"); if (d) setStats(d); break; } | |
| case "users": { const d = await fetchData("users", search ? { search } : {}); if (d) { setUsers(d.users); setUserTotal(d.total); } break; } | |
| case "scans": { const d = await fetchData("scans"); if (d) { setScans(d.scans); setScanTotal(d.total); } break; } | |
| case "teams": { const d = await fetchData("teams"); if (d) setTeams(d.teams); break; } | |
| case "api-keys": { const d = await fetchData("api-keys"); if (d) setApiKeys(d.keys); break; } | |
| case "logs": { const d = await fetchData("logs"); if (d) setLogs(d.logs); break; } | |
| } | |
| setLoading(false); | |
| } | |
| useEffect(() => { loadTab(tab); }, [tab]); | |
| const TABS: { key: Tab; label: string; icon: any }[] = [ | |
| { key: "overview", label: "Overview", icon: LayoutDashboard }, | |
| { key: "users", label: "Users", icon: Users }, | |
| { key: "scans", label: "Scans", icon: ScanText }, | |
| { key: "teams", label: "Teams", icon: Shield }, | |
| { key: "api-keys", label: "API Keys", icon: Key }, | |
| { key: "logs", label: "Activity", icon: Activity }, | |
| ]; | |
| const planBadge = (plan: string) => { | |
| const c: Record<string, string> = { free: "bg-zinc-100 text-zinc-600", pro: "bg-blue-50 text-blue-700", team: "bg-purple-50 text-purple-700" }; | |
| return <span className={`text-[11px] font-medium px-2 py-0.5 rounded ${c[plan] || c.free}`}>{plan}</span>; | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-white"> | |
| <div className="max-w-6xl mx-auto px-5 py-8"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between mb-8"> | |
| <div> | |
| <Link href="/dashboard-pages/dashboard" className="inline-flex items-center gap-1.5 text-sm text-zinc-400 hover:text-zinc-600 mb-2"> | |
| <ArrowLeft className="w-3.5 h-3.5" /> Dashboard | |
| </Link> | |
| <h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2"> | |
| <Crown className="w-6 h-6 text-amber-500" /> | |
| Admin Panel | |
| </h1> | |
| </div> | |
| <button onClick={() => loadTab(tab)} disabled={loading} | |
| className="p-2 rounded-lg hover:bg-zinc-100 text-zinc-400 disabled:opacity-40"> | |
| <RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} /> | |
| </button> | |
| </div> | |
| {/* Tabs */} | |
| <div className="flex gap-1 mb-6 overflow-x-auto pb-1"> | |
| {TABS.map((t) => ( | |
| <button key={t.key} onClick={() => setTab(t.key)} | |
| className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${ | |
| tab === t.key ? "bg-zinc-900 text-white" : "text-zinc-500 hover:bg-zinc-100" | |
| }`}> | |
| <t.icon className="w-4 h-4" /> | |
| {t.label} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Overview */} | |
| {tab === "overview" && stats && ( | |
| <div className="space-y-6"> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> | |
| {[ | |
| { label: "Total Users", value: stats.total_users, sub: `${stats.banned_users} banned` }, | |
| { label: "Pro Users", value: stats.pro_users }, | |
| { label: "Team Users", value: stats.team_users }, | |
| { label: "Free Users", value: stats.free_users }, | |
| { label: "Total Scans", value: stats.total_scans }, | |
| { label: "Scans Today", value: stats.scans_today }, | |
| { label: "Teams", value: stats.total_teams }, | |
| { label: "Active API Keys", value: stats.total_api_keys }, | |
| ].map((s, i) => ( | |
| <div key={i} className="border border-zinc-200 rounded-xl p-4"> | |
| <p className="text-xs text-zinc-400">{s.label}</p> | |
| <p className="text-2xl font-semibold mt-1">{s.value}</p> | |
| {s.sub && <p className="text-xs text-zinc-400 mt-0.5">{s.sub}</p>} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Users */} | |
| {tab === "users" && ( | |
| <div> | |
| <div className="flex gap-2 mb-4"> | |
| <div className="flex-1 relative"> | |
| <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" /> | |
| <input value={search} onChange={(e) => setSearch(e.target.value)} | |
| onKeyDown={(e) => e.key === "Enter" && loadTab("users")} | |
| placeholder="Search by email or name..." | |
| className="w-full pl-10 pr-4 py-2.5 border border-zinc-200 rounded-lg text-sm focus:outline-none focus:border-zinc-400" /> | |
| </div> | |
| <button onClick={() => loadTab("users")} className="px-4 bg-zinc-900 text-white rounded-lg text-sm font-medium hover:bg-zinc-800">Search</button> | |
| </div> | |
| <p className="text-xs text-zinc-400 mb-3">{userTotal} users total</p> | |
| <div className="border border-zinc-200 rounded-xl overflow-hidden"> | |
| <table className="w-full text-sm"> | |
| <thead className="bg-zinc-50 border-b border-zinc-200"> | |
| <tr> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">User</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Plan</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Scans</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Joined</th> | |
| <th className="text-right px-4 py-3 font-medium text-zinc-500">Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-zinc-100"> | |
| {users.map((u) => ( | |
| <tr key={u.id} className={`${u.is_banned ? "opacity-50 bg-red-50/30" : "hover:bg-zinc-50"}`}> | |
| <td className="px-4 py-3"> | |
| <p className="font-medium flex items-center gap-1.5"> | |
| {u.full_name || u.email} | |
| {u.role === "admin" && <Crown className="w-3.5 h-3.5 text-amber-500" />} | |
| {u.is_banned && <Ban className="w-3.5 h-3.5 text-red-500" />} | |
| </p> | |
| <p className="text-xs text-zinc-400">{u.email}</p> | |
| </td> | |
| <td className="px-4 py-3">{planBadge(u.plan)}</td> | |
| <td className="px-4 py-3 text-zinc-500">{u.analyses_this_month}/mo</td> | |
| <td className="px-4 py-3 text-xs text-zinc-400">{new Date(u.created_at).toLocaleDateString()}</td> | |
| <td className="px-4 py-3 text-right"> | |
| <div className="flex gap-1 justify-end"> | |
| <select defaultValue={u.plan} onChange={(e) => adminAction({ action: "update_plan", userId: u.id, plan: e.target.value })} | |
| className="text-xs border border-zinc-200 rounded px-2 py-1 bg-white"> | |
| <option value="free">Free</option> | |
| <option value="pro">Pro</option> | |
| <option value="team">Team</option> | |
| </select> | |
| <button onClick={() => adminAction({ action: "ban_user", userId: u.id, banned: !u.is_banned })} | |
| className={`p-1.5 rounded hover:bg-zinc-100 ${u.is_banned ? "text-emerald-500" : "text-red-400"}`} | |
| title={u.is_banned ? "Unban" : "Ban"}> | |
| {u.is_banned ? <CircleCheck className="w-3.5 h-3.5" /> : <Ban className="w-3.5 h-3.5" />} | |
| </button> | |
| </div> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| {/* Scans */} | |
| {tab === "scans" && ( | |
| <div> | |
| <p className="text-xs text-zinc-400 mb-3">{scanTotal} scans total</p> | |
| <div className="border border-zinc-200 rounded-xl overflow-hidden"> | |
| <table className="w-full text-sm"> | |
| <thead className="bg-zinc-50 border-b border-zinc-200"> | |
| <tr> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Source</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Grade</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Risk</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Clauses</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Date</th> | |
| <th className="text-right px-4 py-3 font-medium text-zinc-500">Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-zinc-100"> | |
| {scans.map((s) => ( | |
| <tr key={s.id} className="hover:bg-zinc-50"> | |
| <td className="px-4 py-3 max-w-xs truncate text-zinc-700">{s.source_url || "Manual scan"}</td> | |
| <td className="px-4 py-3"> | |
| <span className={`text-xs font-semibold px-2 py-0.5 rounded ${ | |
| s.grade === "F" || s.grade === "D" ? "bg-red-50 text-red-700" : | |
| s.grade === "C" ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700" | |
| }`}>{s.grade}</span> | |
| </td> | |
| <td className="px-4 py-3 text-zinc-500">{s.risk_score}/100</td> | |
| <td className="px-4 py-3 text-zinc-500">{s.flagged_count}/{s.total_clauses}</td> | |
| <td className="px-4 py-3 text-xs text-zinc-400">{new Date(s.created_at).toLocaleDateString()}</td> | |
| <td className="px-4 py-3 text-right"> | |
| <button onClick={() => adminAction({ action: "delete_scan", scanId: s.id })} | |
| className="p-1.5 rounded hover:bg-red-50 text-red-400" title="Delete"> | |
| <Trash2 className="w-3.5 h-3.5" /> | |
| </button> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| {/* Teams */} | |
| {tab === "teams" && ( | |
| <div className="border border-zinc-200 rounded-xl overflow-hidden"> | |
| <table className="w-full text-sm"> | |
| <thead className="bg-zinc-50 border-b border-zinc-200"> | |
| <tr> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Team</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Owner</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Seats</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Created</th> | |
| <th className="text-right px-4 py-3 font-medium text-zinc-500">Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-zinc-100"> | |
| {teams.map((t) => ( | |
| <tr key={t.id} className="hover:bg-zinc-50"> | |
| <td className="px-4 py-3 font-medium">{t.name}</td> | |
| <td className="px-4 py-3 text-xs text-zinc-400 font-mono">{t.owner_id.slice(0, 8)}...</td> | |
| <td className="px-4 py-3 text-zinc-500">{t.max_seats}</td> | |
| <td className="px-4 py-3 text-xs text-zinc-400">{new Date(t.created_at).toLocaleDateString()}</td> | |
| <td className="px-4 py-3 text-right"> | |
| <button onClick={() => adminAction({ action: "delete_team", teamId: t.id })} | |
| className="p-1.5 rounded hover:bg-red-50 text-red-400" title="Delete team"> | |
| <Trash2 className="w-3.5 h-3.5" /> | |
| </button> | |
| </td> | |
| </tr> | |
| ))} | |
| {teams.length === 0 && <tr><td colSpan={5} className="px-4 py-8 text-center text-zinc-400">No teams yet.</td></tr>} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| {/* API Keys */} | |
| {tab === "api-keys" && ( | |
| <div className="border border-zinc-200 rounded-xl overflow-hidden"> | |
| <table className="w-full text-sm"> | |
| <thead className="bg-zinc-50 border-b border-zinc-200"> | |
| <tr> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Name</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Key</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Usage</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Status</th> | |
| <th className="text-right px-4 py-3 font-medium text-zinc-500">Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-zinc-100"> | |
| {apiKeys.map((k) => ( | |
| <tr key={k.id} className="hover:bg-zinc-50"> | |
| <td className="px-4 py-3 font-medium">{k.name}</td> | |
| <td className="px-4 py-3 text-xs text-zinc-400 font-mono">{k.key_prefix}</td> | |
| <td className="px-4 py-3 text-zinc-500">{k.calls_this_month}/{k.calls_limit}</td> | |
| <td className="px-4 py-3"> | |
| <span className={`text-xs font-medium px-2 py-0.5 rounded ${k.is_active ? "bg-emerald-50 text-emerald-700" : "bg-red-50 text-red-700"}`}> | |
| {k.is_active ? "Active" : "Revoked"} | |
| </span> | |
| </td> | |
| <td className="px-4 py-3 text-right"> | |
| {k.is_active && ( | |
| <button onClick={() => adminAction({ action: "revoke_api_key", keyId: k.id })} | |
| className="p-1.5 rounded hover:bg-red-50 text-red-400" title="Revoke"> | |
| <Ban className="w-3.5 h-3.5" /> | |
| </button> | |
| )} | |
| </td> | |
| </tr> | |
| ))} | |
| {apiKeys.length === 0 && <tr><td colSpan={5} className="px-4 py-8 text-center text-zinc-400">No API keys.</td></tr>} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| {/* Activity Logs */} | |
| {tab === "logs" && ( | |
| <div className="border border-zinc-200 rounded-xl overflow-hidden"> | |
| <table className="w-full text-sm"> | |
| <thead className="bg-zinc-50 border-b border-zinc-200"> | |
| <tr> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Action</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Target</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Details</th> | |
| <th className="text-left px-4 py-3 font-medium text-zinc-500">Time</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-zinc-100"> | |
| {logs.map((l) => ( | |
| <tr key={l.id} className="hover:bg-zinc-50"> | |
| <td className="px-4 py-3 font-medium">{l.action}</td> | |
| <td className="px-4 py-3 text-xs text-zinc-400">{l.target_type}: {l.target_id?.slice(0, 8)}...</td> | |
| <td className="px-4 py-3 text-xs text-zinc-400 font-mono">{l.details ? JSON.stringify(l.details) : "—"}</td> | |
| <td className="px-4 py-3 text-xs text-zinc-400">{new Date(l.created_at).toLocaleString()}</td> | |
| </tr> | |
| ))} | |
| {logs.length === 0 && <tr><td colSpan={4} className="px-4 py-8 text-center text-zinc-400">No admin actions yet.</td></tr>} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |