feat: add admin frontend layout and dashboard
- Add role field to User interface in AuthContext - Create AdminLayout with responsive sidebar navigation - Create AdminDashboard with stats cards - Add admin routes to App.tsx - Admin panel accessible at /admin - Shows stats: total users, active subscriptions, pending payments - Access control: only users with role=admin can access
This commit is contained in:
@@ -6,6 +6,8 @@ import { Login } from './components/pages/Login'
|
|||||||
import { Register } from './components/pages/Register'
|
import { Register } from './components/pages/Register'
|
||||||
import { OtpVerification } from './components/pages/OtpVerification'
|
import { OtpVerification } from './components/pages/OtpVerification'
|
||||||
import { AuthCallback } from './components/pages/AuthCallback'
|
import { AuthCallback } from './components/pages/AuthCallback'
|
||||||
|
import { AdminLayout } from './components/admin/AdminLayout'
|
||||||
|
import { AdminDashboard } from './components/admin/pages/AdminDashboard'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -59,6 +61,11 @@ export default function App() {
|
|||||||
<Route path="/auth/otp" element={<OtpVerification />} />
|
<Route path="/auth/otp" element={<OtpVerification />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
|
||||||
|
{/* Admin Routes */}
|
||||||
|
<Route path="/admin" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
|
||||||
|
<Route index element={<AdminDashboard />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Protected Routes */}
|
{/* Protected Routes */}
|
||||||
<Route path="/*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
<Route path="/*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
165
apps/web/src/components/admin/AdminLayout.tsx
Normal file
165
apps/web/src/components/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { Link, useLocation, Outlet } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
CreditCard,
|
||||||
|
Wallet,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||||
|
{ name: 'Plans', href: '/admin/plans', icon: CreditCard },
|
||||||
|
{ name: 'Payment Methods', href: '/admin/payment-methods', icon: Wallet },
|
||||||
|
{ name: 'Payments', href: '/admin/payments', icon: CreditCard },
|
||||||
|
{ name: 'Users', href: '/admin/users', icon: Users },
|
||||||
|
{ name: 'Settings', href: '/admin/settings', icon: Settings },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
if (user?.role !== 'admin') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Access Denied
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
You don't have permission to access the admin panel.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="mt-4 inline-block text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
Go to Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Mobile sidebar backdrop */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-600 bg-opacity-75 z-20 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div
|
||||||
|
className={`fixed inset-y-0 left-0 z-30 w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transform transition-transform duration-300 ease-in-out lg:translate-x-0 ${
|
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Admin Panel
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className="lg:hidden text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive = location.pathname === item.href
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5 mr-3" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User info & logout */}
|
||||||
|
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center mb-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
|
||||||
|
{user?.name?.[0] || user?.email[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{user?.name || 'Admin'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/30 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="lg:pl-64">
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="sticky top-0 z-10 flex items-center h-16 px-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 lg:px-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="lg:hidden text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 flex items-center justify-between lg:justify-end">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white lg:hidden">
|
||||||
|
Admin
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{new Date().toLocaleDateString('id-ID', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="p-4 lg:p-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
apps/web/src/components/admin/pages/AdminDashboard.tsx
Normal file
153
apps/web/src/components/admin/pages/AdminDashboard.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { Users, CreditCard, DollarSign, TrendingUp } from 'lucide-react'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalUsers: number
|
||||||
|
activeSubscriptions: number
|
||||||
|
suspendedUsers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminDashboard() {
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
|
const [pendingPayments, setPendingPayments] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const [statsRes, paymentsRes] = await Promise.all([
|
||||||
|
axios.get(`${API_URL}/api/admin/users/stats`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
axios.get(`${API_URL}/api/admin/payments/pending/count`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
setStats(statsRes.data)
|
||||||
|
setPendingPayments(paymentsRes.data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch stats:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
name: 'Total Users',
|
||||||
|
value: stats?.totalUsers || 0,
|
||||||
|
icon: Users,
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Active Subscriptions',
|
||||||
|
value: stats?.activeSubscriptions || 0,
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: 'bg-green-500',
|
||||||
|
bgColor: 'bg-green-50 dark:bg-green-900/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pending Payments',
|
||||||
|
value: pendingPayments,
|
||||||
|
icon: DollarSign,
|
||||||
|
color: 'bg-yellow-500',
|
||||||
|
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Suspended Users',
|
||||||
|
value: stats?.suspendedUsers || 0,
|
||||||
|
icon: CreditCard,
|
||||||
|
color: 'bg-red-500',
|
||||||
|
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
||||||
|
Welcome to the admin panel
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||||
|
{statCards.map((stat) => (
|
||||||
|
<div
|
||||||
|
key={stat.name}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={`p-3 rounded-lg ${stat.bgColor}`}>
|
||||||
|
<stat.icon className={`h-6 w-6 text-white ${stat.color}`} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
{stat.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stat.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Quick Actions
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<a
|
||||||
|
href="/admin/plans"
|
||||||
|
className="flex items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<CreditCard className="h-5 w-5 text-blue-600 dark:text-blue-400 mr-3" />
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Manage Plans
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/payments"
|
||||||
|
className="flex items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<DollarSign className="h-5 w-5 text-green-600 dark:text-green-400 mr-3" />
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Verify Payments
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/users"
|
||||||
|
className="flex items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400 mr-3" />
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Manage Users
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ interface User {
|
|||||||
name: string | null
|
name: string | null
|
||||||
avatarUrl: string | null
|
avatarUrl: string | null
|
||||||
emailVerified: boolean
|
emailVerified: boolean
|
||||||
|
role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user