Compare commits
2 Commits
e84d4affc6
...
7396cb5bd6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7396cb5bd6 | ||
|
|
cd6b047d3f |
@@ -6,6 +6,13 @@ import { Login } from './components/pages/Login'
|
||||
import { Register } from './components/pages/Register'
|
||||
import { OtpVerification } from './components/pages/OtpVerification'
|
||||
import { AuthCallback } from './components/pages/AuthCallback'
|
||||
import { AdminLayout } from './components/admin/AdminLayout'
|
||||
import { AdminDashboard } from './components/admin/pages/AdminDashboard'
|
||||
import { AdminPlans } from './components/admin/pages/AdminPlans'
|
||||
import { AdminPaymentMethods } from './components/admin/pages/AdminPaymentMethods'
|
||||
import { AdminPayments } from './components/admin/pages/AdminPayments'
|
||||
import { AdminUsers } from './components/admin/pages/AdminUsers'
|
||||
import { AdminSettings } from './components/admin/pages/AdminSettings'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -59,6 +66,16 @@ export default function App() {
|
||||
<Route path="/auth/otp" element={<OtpVerification />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route path="/admin" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="plans" element={<AdminPlans />} />
|
||||
<Route path="payment-methods" element={<AdminPaymentMethods />} />
|
||||
<Route path="payments" element={<AdminPayments />} />
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
</Route>
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route path="/*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
12
apps/web/src/components/admin/pages/AdminPaymentMethods.tsx
Normal file
12
apps/web/src/components/admin/pages/AdminPaymentMethods.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export function AdminPaymentMethods() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Payment Methods
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Manage payment methods (Coming soon)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
apps/web/src/components/admin/pages/AdminPayments.tsx
Normal file
12
apps/web/src/components/admin/pages/AdminPayments.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export function AdminPayments() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Payment Verification
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Verify pending payments (Coming soon)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
248
apps/web/src/components/admin/pages/AdminPlans.tsx
Normal file
248
apps/web/src/components/admin/pages/AdminPlans.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Plus, Edit, Trash2, Eye, EyeOff, GripVertical } from 'lucide-react'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||
|
||||
interface Plan {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
price: string
|
||||
currency: string
|
||||
durationType: string
|
||||
durationDays: number | null
|
||||
trialDays: number
|
||||
features: any
|
||||
badge: string | null
|
||||
badgeColor: string | null
|
||||
sortOrder: number
|
||||
isActive: boolean
|
||||
isVisible: boolean
|
||||
isFeatured: boolean
|
||||
_count: {
|
||||
subscriptions: number
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminPlans() {
|
||||
const [plans, setPlans] = useState<Plan[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingPlan, setEditingPlan] = useState<Plan | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans()
|
||||
}, [])
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get(`${API_URL}/api/admin/plans`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
setPlans(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plans:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this plan?')) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.delete(`${API_URL}/api/admin/plans/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
fetchPlans()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete plan:', error)
|
||||
alert('Failed to delete plan')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleVisibility = async (plan: Plan) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.put(
|
||||
`${API_URL}/api/admin/plans/${plan.id}`,
|
||||
{ isVisible: !plan.isVisible },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
fetchPlans()
|
||||
} catch (error) {
|
||||
console.error('Failed to update plan:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (price: string, currency: string) => {
|
||||
const amount = parseInt(price)
|
||||
if (amount === 0) return 'Free'
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-600 dark:text-gray-400">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Plans Management
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Manage subscription plans
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingPlan(null)
|
||||
setShowModal(true)
|
||||
}}
|
||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Add Plan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Plans Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center">
|
||||
<GripVertical className="h-5 w-5 text-gray-400 mr-2 cursor-move" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{plan.name}
|
||||
</h3>
|
||||
</div>
|
||||
{plan.badge && (
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
plan.badgeColor === 'blue'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400'
|
||||
: plan.badgeColor === 'green'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{plan.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatPrice(plan.price, plan.currency)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{plan.durationType === 'lifetime'
|
||||
? 'Lifetime access'
|
||||
: plan.durationType === 'monthly'
|
||||
? 'per month'
|
||||
: plan.durationType === 'yearly'
|
||||
? 'per year'
|
||||
: plan.durationType}
|
||||
</div>
|
||||
{plan.trialDays > 0 && (
|
||||
<div className="text-sm text-blue-600 dark:text-blue-400 mt-1">
|
||||
{plan.trialDays} days free trial
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Subscriptions:
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{plan._count.subscriptions}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm mt-2">
|
||||
<span className="text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
{plan.isActive && (
|
||||
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400 rounded">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{plan.isVisible ? (
|
||||
<Eye className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => toggleVisibility(plan)}
|
||||
className="p-2 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
|
||||
title={plan.isVisible ? 'Hide' : 'Show'}
|
||||
>
|
||||
{plan.isVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingPlan(plan)
|
||||
setShowModal(true)
|
||||
}}
|
||||
className="p-2 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(plan.id)}
|
||||
className="p-2 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{plans.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">No plans found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
apps/web/src/components/admin/pages/AdminSettings.tsx
Normal file
12
apps/web/src/components/admin/pages/AdminSettings.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export function AdminSettings() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
App Settings
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Manage app configuration (Coming soon)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
234
apps/web/src/components/admin/pages/AdminUsers.tsx
Normal file
234
apps/web/src/components/admin/pages/AdminUsers.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Search, UserX, UserCheck, Crown } from 'lucide-react'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
role: string
|
||||
emailVerified: boolean
|
||||
createdAt: string
|
||||
lastLoginAt: string | null
|
||||
suspendedAt: string | null
|
||||
_count: {
|
||||
wallets: number
|
||||
transactions: number
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminUsers() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [search])
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const params = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
const response = await axios.get(`${API_URL}/api/admin/users${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
setUsers(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuspend = async (userId: string, suspend: boolean) => {
|
||||
const reason = suspend
|
||||
? prompt('Enter suspension reason:')
|
||||
: null
|
||||
|
||||
if (suspend && !reason) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const endpoint = suspend ? 'suspend' : 'unsuspend'
|
||||
await axios.post(
|
||||
`${API_URL}/api/admin/users/${userId}/${endpoint}`,
|
||||
suspend ? { reason } : {},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('Failed to update user:', error)
|
||||
alert('Failed to update user')
|
||||
}
|
||||
}
|
||||
|
||||
const handleGrantPro = async (userId: string) => {
|
||||
const days = prompt('Enter duration in days (e.g., 30 for monthly):')
|
||||
if (!days || isNaN(parseInt(days))) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post(
|
||||
`${API_URL}/api/admin/users/${userId}/grant-pro`,
|
||||
{
|
||||
planSlug: 'pro-monthly',
|
||||
durationDays: parseInt(days),
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
alert('Pro access granted successfully!')
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('Failed to grant pro access:', error)
|
||||
alert('Failed to grant pro access')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-600 dark:text-gray-400">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Users Management
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Manage user accounts and permissions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by email or name..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Stats
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 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 className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{user.name || 'No name'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
<div>{user._count.wallets} wallets</div>
|
||||
<div>{user._count.transactions} transactions</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{user.suspendedAt ? (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400 rounded">
|
||||
Suspended
|
||||
</span>
|
||||
) : user.emailVerified ? (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400 rounded">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400 rounded">
|
||||
Unverified
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
{user.suspendedAt ? (
|
||||
<button
|
||||
onClick={() => handleSuspend(user.id, false)}
|
||||
className="text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
title="Unsuspend"
|
||||
>
|
||||
<UserCheck className="h-4 w-4 inline" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSuspend(user.id, true)}
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="Suspend"
|
||||
>
|
||||
<UserX className="h-4 w-4 inline" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleGrantPro(user.id)}
|
||||
className="text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
|
||||
title="Grant Pro Access"
|
||||
>
|
||||
<Crown className="h-4 w-4 inline" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{users.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">No users found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface User {
|
||||
name: string | null
|
||||
avatarUrl: string | null
|
||||
emailVerified: boolean
|
||||
role?: string
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
|
||||
Reference in New Issue
Block a user