feat: reorganize admin settings with tabbed interface and documentation

- Reorganized admin settings into tabbed interface (General, Security, Payment Methods)
- Vertical tabs on desktop, horizontal scrollable on mobile
- Moved Payment Methods from separate menu to Settings tab
- Fixed admin profile reuse and dashboard blocking
- Fixed maintenance mode guard to use AppConfig model
- Added admin auto-redirect after login (admins → /admin, users → /)
- Reorganized documentation into docs/ folder structure
- Created comprehensive README and documentation index
- Added PWA and Web Push notifications to to-do list
This commit is contained in:
dwindown
2025-10-13 09:28:12 +07:00
parent 49d60676d0
commit 89f881e7cf
99 changed files with 4884 additions and 392 deletions

View File

@@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LanguageProvider } from './contexts/LanguageContext'
import { ThemeProvider } from './components/ThemeProvider'
@@ -8,14 +9,16 @@ 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 { MaintenancePage } from './components/pages/MaintenancePage'
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 { AdminSettings } from './components/admin/pages/AdminSettingsNew'
import { Profile } from './components/pages/Profile'
import { Loader2 } from 'lucide-react'
import { setupAxiosInterceptors } from './utils/axiosSetup'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
@@ -50,13 +53,35 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
}
if (user) {
return <Navigate to="/" replace />
// Redirect based on role
const redirectTo = user.role === 'admin' ? '/admin' : '/'
return <Navigate to={redirectTo} replace />
}
return <>{children}</>
}
export default function App() {
const [maintenanceMode, setMaintenanceMode] = useState(false)
const [maintenanceMessage, setMaintenanceMessage] = useState('')
useEffect(() => {
// Setup axios interceptor for maintenance mode
setupAxiosInterceptors((message) => {
setMaintenanceMessage(message)
setMaintenanceMode(true)
})
}, [])
// Show maintenance page if maintenance mode is active
if (maintenanceMode) {
return (
<ThemeProvider defaultTheme="light" storageKey="tabungin-ui-theme">
<MaintenancePage message={maintenanceMessage} />
</ThemeProvider>
)
}
return (
<BrowserRouter>
<ThemeProvider defaultTheme="light" storageKey="tabungin-ui-theme">
@@ -74,9 +99,9 @@ export default function App() {
<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="profile" element={<Profile />} />
<Route path="settings" element={<AdminSettings />} />
</Route>

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from "react"
import { Routes, Route, useLocation, useNavigate } from "react-router-dom"
import { Routes, Route, useLocation, useNavigate, Navigate } from "react-router-dom"
import { useAuth } from "@/contexts/AuthContext"
import { DashboardLayout } from "./layout/DashboardLayout"
import { Overview } from "./pages/Overview"
import { Wallets } from "./pages/Wallets"
@@ -7,8 +8,14 @@ import { Transactions } from "./pages/Transactions"
import { Profile } from "./pages/Profile"
export function Dashboard() {
const { user } = useAuth()
const location = useLocation()
const navigate = useNavigate()
// Block admins from accessing member dashboard
if (user?.role === 'admin') {
return <Navigate to="/admin" replace />
}
const [fabWalletDialogOpen, setFabWalletDialogOpen] = useState(false)
const [fabTransactionDialogOpen, setFabTransactionDialogOpen] = useState(false)

View File

@@ -16,16 +16,16 @@ export function AdminLayout() {
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<h1 className="text-2xl font-bold text-foreground mb-2">
Akses Ditolak
Access Denied
</h1>
<p className="text-muted-foreground">
Anda tidak memiliki izin untuk mengakses panel admin.
You don't have permission to access the admin panel.
</p>
<Link
to="/"
className="mt-4 inline-block text-primary hover:text-primary/90"
>
Kembali ke Dashboard
Back to Dashboard
</Link>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { LayoutDashboard, CreditCard, Wallet, Users, Settings, LogOut } from 'lucide-react'
import { LayoutDashboard, CreditCard, Wallet, Users, Settings, LogOut, UserCircle } from 'lucide-react'
import { Logo } from '../Logo'
import {
Sidebar,
@@ -26,21 +26,21 @@ const items = [
url: '/admin/plans',
icon: CreditCard,
},
{
title: 'Payment Methods',
url: '/admin/payment-methods',
icon: Wallet,
},
{
title: 'Payments',
url: '/admin/payments',
icon: CreditCard,
icon: Wallet,
},
{
title: 'Users',
url: '/admin/users',
icon: Users,
},
{
title: 'Profile',
url: '/admin/profile',
icon: UserCircle,
},
{
title: 'Settings',
url: '/admin/settings',

View File

@@ -115,7 +115,7 @@ export function AdminDashboard() {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Memuat...</div>
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
@@ -127,7 +127,7 @@ export function AdminDashboard() {
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="mt-2 text-muted-foreground">
Selamat datang di panel admin - Overview performa aplikasi
Welcome to admin panel - Application performance overview
</p>
</div>
@@ -143,7 +143,7 @@ export function AdminDashboard() {
<p className="text-xs text-muted-foreground flex items-center mt-1">
<ArrowUpRight className="h-3 w-3 text-green-600 mr-1" />
<span className="text-green-600">+{stats?.userGrowth || 0}%</span>
<span className="ml-1">dari bulan lalu</span>
<span className="ml-1">from last month</span>
</p>
</CardContent>
</Card>
@@ -156,7 +156,7 @@ export function AdminDashboard() {
<CardContent>
<div className="text-2xl font-bold">{stats?.activeSubscriptions || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
Langganan aktif saat ini
Currently active subscriptions
</p>
</CardContent>
</Card>
@@ -171,7 +171,7 @@ export function AdminDashboard() {
<p className="text-xs text-muted-foreground flex items-center mt-1">
<ArrowUpRight className="h-3 w-3 text-green-600 mr-1" />
<span className="text-green-600">+{stats?.revenueGrowth || 0}%</span>
<span className="ml-1">dari bulan lalu</span>
<span className="ml-1">from last month</span>
</p>
</CardContent>
</Card>
@@ -184,7 +184,7 @@ export function AdminDashboard() {
<CardContent>
<div className="text-2xl font-bold">{pendingPayments}</div>
<p className="text-xs text-muted-foreground mt-1">
Menunggu verifikasi
Awaiting verification
</p>
</CardContent>
</Card>
@@ -196,7 +196,7 @@ export function AdminDashboard() {
<Card>
<CardHeader>
<CardTitle>Revenue Overview</CardTitle>
<CardDescription>Pendapatan 6 bulan terakhir</CardDescription>
<CardDescription>Revenue for the last 6 months</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
@@ -228,7 +228,7 @@ export function AdminDashboard() {
<Card>
<CardHeader>
<CardTitle>Subscription Distribution</CardTitle>
<CardDescription>Distribusi pengguna per plan</CardDescription>
<CardDescription>User distribution by plan</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
@@ -256,19 +256,19 @@ export function AdminDashboard() {
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Akses cepat ke fitur utama</CardDescription>
<CardDescription>Quick access to main features</CardDescription>
</CardHeader>
<CardContent className="grid gap-2">
<Button variant="outline" className="justify-start" asChild>
<a href="/admin/plans">
<CreditCard className="h-4 w-4 mr-2" />
Kelola Plans
Manage Plans
</a>
</Button>
<Button variant="outline" className="justify-start" asChild>
<a href="/admin/payments">
<DollarSign className="h-4 w-4 mr-2" />
Verifikasi Pembayaran
Verify Payments
{pendingPayments > 0 && (
<Badge variant="destructive" className="ml-auto">{pendingPayments}</Badge>
)}
@@ -277,13 +277,13 @@ export function AdminDashboard() {
<Button variant="outline" className="justify-start" asChild>
<a href="/admin/users">
<Users className="h-4 w-4 mr-2" />
Kelola Users
Manage Users
</a>
</Button>
<Button variant="outline" className="justify-start" asChild>
<a href="/admin/payment-methods">
<Wallet className="h-4 w-4 mr-2" />
Metode Pembayaran
Payment Methods
</a>
</Button>
</CardContent>
@@ -293,7 +293,7 @@ export function AdminDashboard() {
<Card>
<CardHeader>
<CardTitle>System Status</CardTitle>
<CardDescription>Status sistem dan statistik</CardDescription>
<CardDescription>System status and statistics</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">

View File

@@ -171,7 +171,7 @@ function SortableMethodCard({ method, onEdit, onDelete, onToggleActive, getTypeI
{/* Instructions */}
{method.instructions && (
<div className="mb-6 p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground mb-1">Instruksi:</p>
<p className="text-xs text-muted-foreground mb-1">Instruction:</p>
<p className="text-sm text-foreground line-clamp-3">
{method.instructions}
</p>
@@ -230,7 +230,7 @@ function SortableMethodCard({ method, onEdit, onDelete, onToggleActive, getTypeI
<button
onClick={() => onDelete(method.id)}
className="p-2.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-destructive-foreground transition-all"
title="Hapus"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
@@ -301,10 +301,10 @@ export function AdminPaymentMethods() {
{ methodIds: newMethods.map((m) => m.id) },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('Urutan metode pembayaran berhasil diubah')
toast.success('Payment method order updated successfully')
} catch (error) {
console.error('Failed to reorder methods:', error)
toast.error('Gagal mengubah urutan metode pembayaran')
toast.error('Failed to update payment method order')
fetchMethods() // Revert on error
}
}
@@ -319,11 +319,11 @@ export function AdminPaymentMethods() {
{ ...method, isActive: !method.isActive },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success(method.isActive ? 'Metode pembayaran dinonaktifkan' : 'Metode pembayaran diaktifkan')
toast.success(method.isActive ? 'Payment method deactivated' : 'Payment method activated')
fetchMethods()
} catch (error) {
console.error('Failed to toggle status:', error)
toast.error('Gagal mengubah status')
toast.error('Failed to change status')
}
}
@@ -337,12 +337,12 @@ export function AdminPaymentMethods() {
await axios.delete(`${API_URL}/api/admin/payment-methods/${deleteDialog.methodId}`, {
headers: { Authorization: `Bearer ${token}` },
})
toast.success('Metode pembayaran berhasil dihapus')
toast.success('Payment method deleted successfully')
fetchMethods()
setDeleteDialog({ open: false, methodId: '' })
} catch (error) {
console.error('Failed to delete payment method:', error)
toast.error('Gagal menghapus metode pembayaran')
toast.error('Failed to delete payment method')
}
}
@@ -391,13 +391,13 @@ export function AdminPaymentMethods() {
headers: { Authorization: `Bearer ${token}` },
})
}
toast.success(editingMethod ? 'Metode pembayaran berhasil diupdate' : 'Metode pembayaran berhasil ditambahkan')
toast.success(editingMethod ? 'Payment method updated successfully' : 'Payment method added successfully')
fetchMethods()
setShowModal(false)
setEditingMethod(null)
} catch (error) {
console.error('Failed to save payment method:', error)
toast.error('Gagal menyimpan metode pembayaran')
toast.error('Failed to save payment method')
}
}
@@ -443,7 +443,7 @@ export function AdminPaymentMethods() {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Memuat...</div>
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
@@ -452,16 +452,16 @@ export function AdminPaymentMethods() {
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground">
Metode Pembayaran
</h1>
<h4 className="text-xl font-bold text-foreground">
Payment Methods
</h4>
<p className="mt-2 text-muted-foreground">
Kelola metode pembayaran yang tersedia
Manage available payment methods
</p>
</div>
<Button onClick={() => handleOpenModal()}>
<Plus className="h-5 w-5 mr-2" />
Tambah Metode
Add Method
</Button>
</div>
@@ -487,7 +487,7 @@ export function AdminPaymentMethods() {
{methods.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">Belum ada metode pembayaran</p>
<p className="text-muted-foreground">No payment methods yet</p>
</div>
)}
@@ -495,21 +495,21 @@ export function AdminPaymentMethods() {
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingMethod ? 'Edit Metode Pembayaran' : 'Tambah Metode Pembayaran'}</DialogTitle>
<DialogTitle>{editingMethod ? 'Edit Payment Method' : 'Add Payment Method'}</DialogTitle>
<DialogDescription>
{editingMethod ? 'Ubah informasi metode pembayaran' : 'Tambah metode pembayaran baru'}
{editingMethod ? 'Update payment method information' : 'Add a new payment method'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">Nama Tampilan</Label>
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
required
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
placeholder="BCA, GoPay, QRIS, dll"
placeholder="BCA, GoPay, QRIS, etc"
/>
</div>
@@ -520,28 +520,28 @@ export function AdminPaymentMethods() {
required
value={formData.provider}
onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
placeholder="BCA, Gopay, OVO, dll"
placeholder="BCA, Gopay, OVO, etc"
/>
</div>
<div className="space-y-2">
<Label htmlFor="type">Tipe</Label>
<Label htmlFor="type">Type</Label>
<Select value={formData.type} onValueChange={(value) => setFormData({ ...formData, type: value })}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bank_transfer">Transfer Bank</SelectItem>
<SelectItem value="bank_transfer">Bank Transfer</SelectItem>
<SelectItem value="ewallet">E-Wallet</SelectItem>
<SelectItem value="qris">QRIS</SelectItem>
<SelectItem value="other">Lainnya</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="accountNumber">Nomor Rekening / Akun</Label>
<Label htmlFor="accountNumber">Account Number</Label>
<Input
id="accountNumber"
value={formData.accountNumber}
@@ -550,7 +550,7 @@ export function AdminPaymentMethods() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="accountName">Nama Pemilik</Label>
<Label htmlFor="accountName">Account Holder Name</Label>
<Input
id="accountName"
value={formData.accountName}
@@ -561,13 +561,13 @@ export function AdminPaymentMethods() {
</div>
<div className="space-y-2">
<Label htmlFor="instructions">Instruksi Pembayaran</Label>
<Label htmlFor="instructions">Payment Instruction</Label>
<Textarea
id="instructions"
value={formData.instructions}
onChange={(e) => setFormData({ ...formData, instructions: e.target.value })}
rows={4}
placeholder="Petunjuk cara melakukan pembayaran..."
placeholder="Detail payment instruction, step by step to make payment..."
/>
</div>
@@ -591,17 +591,17 @@ export function AdminPaymentMethods() {
/>
<Label htmlFor="isActive" className="cursor-pointer flex justify-between w-full">
<div className="font-semibold text-foreground">Active</div>
<div className="text-xs text-muted-foreground">Metode pembayaran dapat digunakan</div>
<div className="text-xs text-muted-foreground">Payment method can be used</div>
</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setShowModal(false)}>
Batal
Cancel
</Button>
<Button type="submit">
{editingMethod ? 'Update' : 'Tambah'}
{editingMethod ? 'Update' : 'Add'}
</Button>
</DialogFooter>
</form>
@@ -612,15 +612,15 @@ export function AdminPaymentMethods() {
<AlertDialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus Metode Pembayaran?</AlertDialogTitle>
<AlertDialogTitle>Delete Payment Method?</AlertDialogTitle>
<AlertDialogDescription>
Apakah Anda yakin ingin menghapus metode pembayaran ini? Tindakan ini tidak dapat dibatalkan.
Are you sure you want to delete this payment method? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Hapus
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -68,7 +68,7 @@ export function AdminPayments() {
}
const handleVerify = async (paymentId: string) => {
const notes = prompt('Catatan verifikasi (opsional):')
const notes = prompt('Verification notes (optional):')
if (notes === null) return
try {
@@ -78,16 +78,16 @@ export function AdminPayments() {
{ notes },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('Pembayaran berhasil diverifikasi')
toast.success('Payment verified successfully')
fetchPayments()
} catch (error) {
console.error('Failed to verify payment:', error)
toast.error('Gagal memverifikasi pembayaran')
toast.error('Failed to verify payment')
}
}
const handleReject = async (paymentId: string) => {
const reason = prompt('Alasan penolakan:')
const reason = prompt('Rejection reason:')
if (!reason) return
try {
@@ -97,11 +97,11 @@ export function AdminPayments() {
{ reason },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('Pembayaran berhasil ditolak')
toast.success('Payment rejected successfully')
fetchPayments()
} catch (error) {
console.error('Failed to reject payment:', error)
toast.error('Gagal menolak pembayaran')
toast.error('Failed to reject payment')
}
}
@@ -154,7 +154,7 @@ export function AdminPayments() {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Memuat...</div>
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
@@ -162,9 +162,9 @@ export function AdminPayments() {
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Verifikasi Pembayaran</h1>
<h1 className="text-3xl font-bold text-foreground">Payment Verification</h1>
<p className="mt-2 text-muted-foreground">
Kelola dan verifikasi bukti pembayaran dari pengguna
Manage and verify payment proofs from users
</p>
</div>
@@ -190,7 +190,7 @@ export function AdminPayments() {
onChange={(e) => setFilter(e.target.value as FilterStatus)}
className="px-4 py-2 border border-input rounded-lg bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-transparent"
>
<option value="all">Semua Status</option>
<option value="all">All Status</option>
<option value="pending">Pending</option>
<option value="verified">Verified</option>
<option value="rejected">Rejected</option>
@@ -211,16 +211,16 @@ export function AdminPayments() {
Plan
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Jumlah
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Metode
Method
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Tanggal
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
@@ -272,7 +272,7 @@ export function AdminPayments() {
<button
onClick={() => setSelectedPayment(payment)}
className="p-2 rounded-lg text-primary hover:bg-primary/10 transition-colors"
title="Lihat Bukti"
title="View Proof"
>
<Eye className="h-4 w-4" />
</button>
@@ -282,14 +282,14 @@ export function AdminPayments() {
<button
onClick={() => handleVerify(payment.id)}
className="p-2 rounded-lg text-green-600 hover:bg-green-500/10 transition-colors"
title="Verifikasi"
title="Verify"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => handleReject(payment.id)}
className="p-2 rounded-lg text-destructive hover:bg-destructive/10 transition-colors"
title="Tolak"
title="Reject"
>
<X className="h-4 w-4" />
</button>
@@ -321,7 +321,7 @@ export function AdminPayments() {
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-border">
<h2 className="text-xl font-bold text-foreground">Bukti Pembayaran</h2>
<h2 className="text-xl font-bold text-foreground">Payment Proof</h2>
<p className="text-sm text-muted-foreground mt-1">
{selectedPayment.user.name} - {selectedPayment.plan.name}
</p>
@@ -330,15 +330,15 @@ export function AdminPayments() {
{selectedPayment.proofUrl ? (
<img
src={selectedPayment.proofUrl}
alt="Bukti Pembayaran"
alt="Payment Proof"
className="w-full rounded-lg"
/>
) : (
<p className="text-muted-foreground">Tidak ada bukti pembayaran</p>
<p className="text-muted-foreground">No payment proof</p>
)}
{selectedPayment.notes && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<p className="text-sm font-medium text-foreground">Catatan:</p>
<p className="text-sm font-medium text-foreground">Notes:</p>
<p className="text-sm text-muted-foreground mt-1">
{selectedPayment.notes}
</p>
@@ -361,7 +361,7 @@ export function AdminPayments() {
}}
className="px-4 py-2 rounded-lg bg-green-500 text-white hover:bg-green-600 transition-colors"
>
Verifikasi
Verify
</button>
<button
onClick={() => {
@@ -370,7 +370,7 @@ export function AdminPayments() {
}}
className="px-4 py-2 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
>
Tolak
Reject
</button>
</>
)}

View File

@@ -242,7 +242,7 @@ function SortablePlanCard({ plan, onEdit, onDelete, onToggleVisibility, formatPr
<button
onClick={() => onDelete(plan.id)}
className="p-2.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-destructive-foreground transition-all"
title="Hapus"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
@@ -317,10 +317,10 @@ export function AdminPlans() {
{ planIds: newPlans.map((p) => p.id) },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('Urutan plan berhasil diubah')
toast.success('Plan order updated successfully')
} catch (error) {
console.error('Failed to reorder plans:', error)
toast.error('Gagal mengubah urutan plan')
toast.error('Failed to update plan order')
fetchPlans() // Revert on error
}
}
@@ -341,14 +341,14 @@ export function AdminPlans() {
if (response.data.action === 'deactivated') {
toast.warning(response.data.message)
} else {
toast.success('Plan berhasil dihapus')
toast.success('Plan deleted successfully')
}
fetchPlans()
setDeleteDialog({ open: false, planId: '' })
} catch (error) {
console.error('Failed to delete plan:', error)
toast.error('Gagal menghapus plan')
toast.error('Failed to delete plan')
}
}
@@ -360,11 +360,11 @@ export function AdminPlans() {
{ isVisible: !plan.isVisible },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success(plan.isVisible ? 'Plan berhasil disembunyikan' : 'Plan berhasil ditampilkan')
toast.success(plan.isVisible ? 'Plan hidden successfully' : 'Plan shown successfully')
fetchPlans()
} catch (error) {
console.error('Failed to update plan:', error)
toast.error('Gagal mengubah visibilitas plan')
toast.error('Failed to change plan visibility')
}
}
@@ -423,13 +423,13 @@ export function AdminPlans() {
headers: { Authorization: `Bearer ${token}` },
})
}
toast.success(editingPlan ? 'Plan berhasil diupdate' : 'Plan berhasil ditambahkan')
toast.success(editingPlan ? 'Plan updated successfully' : 'Plan added successfully')
fetchPlans()
setShowModal(false)
setEditingPlan(null)
} catch (error) {
console.error('Failed to save plan:', error)
toast.error('Gagal menyimpan plan')
toast.error('Failed to save plan')
}
}
@@ -446,7 +446,7 @@ export function AdminPlans() {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Memuat...</div>
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
@@ -455,12 +455,12 @@ export function AdminPlans() {
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground">Kelola Plans</h1>
<p className="mt-2 text-muted-foreground">Kelola paket berlangganan</p>
<h1 className="text-3xl font-bold text-foreground">Manage Plans</h1>
<p className="mt-2 text-muted-foreground">Manage subscription plans</p>
</div>
<Button onClick={() => handleOpenModal()}>
<Plus className="h-5 w-5 mr-2" />
Tambah Plan
Add Plan
</Button>
</div>
@@ -486,16 +486,16 @@ export function AdminPlans() {
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingPlan ? 'Edit Plan' : 'Tambah Plan Baru'}</DialogTitle>
<DialogTitle>{editingPlan ? 'Edit Plan' : 'Add New Plan'}</DialogTitle>
<DialogDescription>
{editingPlan ? 'Ubah informasi plan berlangganan' : 'Buat plan berlangganan baru'}
{editingPlan ? 'Update subscription plan information' : 'Create a new subscription plan'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Nama Plan</Label>
<Label htmlFor="name">Plan Name</Label>
<Input
id="name"
required
@@ -515,7 +515,7 @@ export function AdminPlans() {
</div>
<div className="space-y-2">
<Label htmlFor="description">Deskripsi</Label>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
required
@@ -527,7 +527,7 @@ export function AdminPlans() {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="price">Harga</Label>
<Label htmlFor="price">Price</Label>
<Input
id="price"
type="number"
@@ -552,7 +552,7 @@ export function AdminPlans() {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="durationType">Tipe Durasi</Label>
<Label htmlFor="durationType">Duration Type</Label>
<Select value={formData.durationType} onValueChange={(value) => setFormData({ ...formData, durationType: value })}>
<SelectTrigger id="durationType">
<SelectValue />
@@ -576,7 +576,7 @@ export function AdminPlans() {
</div>
<div className="space-y-2">
<Label htmlFor="features">Features (satu per baris)</Label>
<Label htmlFor="features">Features (one per line)</Label>
<Textarea
id="features"
value={formData.features.join('\n')}
@@ -636,7 +636,7 @@ export function AdminPlans() {
/>
<Label htmlFor="isVisible" className="cursor-pointer flex justify-between w-full">
<div className="font-semibold text-foreground">Visible</div>
<div className="text-xs text-muted-foreground">Tampil di halaman pricing</div>
<div className="text-xs text-muted-foreground">Show on pricing page</div>
</Label>
</div>
</div>
@@ -644,10 +644,10 @@ export function AdminPlans() {
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setShowModal(false)}>
Batal
Cancel
</Button>
<Button type="submit">
{editingPlan ? 'Update' : 'Tambah'}
{editingPlan ? 'Update' : 'Add'}
</Button>
</DialogFooter>
</form>
@@ -658,15 +658,15 @@ export function AdminPlans() {
<AlertDialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus Plan?</AlertDialogTitle>
<AlertDialogTitle>Delete Plan?</AlertDialogTitle>
<AlertDialogDescription>
Apakah Anda yakin ingin menghapus plan ini? Tindakan ini tidak dapat dibatalkan.
Are you sure you want to delete this plan? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Hapus
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -29,7 +29,7 @@ export function AdminSettings() {
enableEmailVerification: true,
enablePaymentVerification: true,
maintenanceMode: false,
maintenanceMessage: 'Sistem sedang dalam pemeliharaan. Mohon coba lagi nanti.',
maintenanceMessage: 'System is under maintenance. Please try again later.',
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -65,7 +65,7 @@ export function AdminSettings() {
enableEmailVerification: configData.features?.find((c) => c.key === 'enable_email_verification')?.value === 'true',
enablePaymentVerification: configData.features?.find((c) => c.key === 'enable_payment_verification')?.value === 'true',
maintenanceMode: configData.system?.find((c) => c.key === 'maintenance_mode')?.value === 'true',
maintenanceMessage: configData.system?.find((c) => c.key === 'maintenance_message')?.value || 'Sistem sedang dalam pemeliharaan. Mohon coba lagi nanti.',
maintenanceMessage: configData.system?.find((c) => c.key === 'maintenance_message')?.value || 'System is under maintenance. Please try again later.',
}
setSettings(settingsObj)
} catch (error) {
@@ -82,14 +82,14 @@ export function AdminSettings() {
// Save each setting individually
const configUpdates = [
{ key: 'app_name', value: settings.appName, category: 'general', label: 'Nama Aplikasi', type: 'text' },
{ key: 'app_url', value: settings.appUrl, category: 'general', label: 'URL Aplikasi', type: 'text' },
{ key: 'support_email', value: settings.supportEmail, category: 'general', label: 'Email Support', type: 'email' },
{ key: 'enable_registration', value: String(settings.enableRegistration), category: 'features', label: 'Registrasi Pengguna Baru', type: 'boolean' },
{ key: 'enable_email_verification', value: String(settings.enableEmailVerification), category: 'features', label: 'Verifikasi Email', type: 'boolean' },
{ key: 'enable_payment_verification', value: String(settings.enablePaymentVerification), category: 'features', label: 'Verifikasi Pembayaran', type: 'boolean' },
{ key: 'maintenance_mode', value: String(settings.maintenanceMode), category: 'system', label: 'Mode Pemeliharaan', type: 'boolean' },
{ key: 'maintenance_message', value: settings.maintenanceMessage, category: 'system', label: 'Pesan Pemeliharaan', type: 'text' },
{ key: 'app_name', value: settings.appName, category: 'general', label: 'Application Name', type: 'text' },
{ key: 'app_url', value: settings.appUrl, category: 'general', label: 'Application URL', type: 'text' },
{ key: 'support_email', value: settings.supportEmail, category: 'general', label: 'Support Email', type: 'email' },
{ key: 'enable_registration', value: String(settings.enableRegistration), category: 'features', label: 'New User Registration', type: 'boolean' },
{ key: 'enable_email_verification', value: String(settings.enableEmailVerification), category: 'features', label: 'Email Verification', type: 'boolean' },
{ key: 'enable_payment_verification', value: String(settings.enablePaymentVerification), category: 'features', label: 'Payment Verification', type: 'boolean' },
{ key: 'maintenance_mode', value: String(settings.maintenanceMode), category: 'system', label: 'Maintenance Mode', type: 'boolean' },
{ key: 'maintenance_message', value: settings.maintenanceMessage, category: 'system', label: 'Maintenance Message', type: 'text' },
]
await Promise.all(
@@ -100,11 +100,11 @@ export function AdminSettings() {
)
)
toast.success('Pengaturan berhasil disimpan')
toast.success('Settings saved successfully')
fetchSettings() // Refresh
} catch (error) {
console.error('Failed to save settings:', error)
toast.error('Gagal menyimpan pengaturan')
toast.error('Failed to save settings')
} finally {
setSaving(false)
}
@@ -117,7 +117,7 @@ export function AdminSettings() {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Memuat...</div>
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
@@ -125,9 +125,9 @@ export function AdminSettings() {
return (
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Pengaturan Aplikasi</h1>
<h1 className="text-3xl font-bold text-foreground">Application Settings</h1>
<p className="mt-2 text-muted-foreground">
Kelola konfigurasi dan pengaturan sistem
Manage system configuration and settings
</p>
</div>
@@ -139,15 +139,15 @@ export function AdminSettings() {
<Globe className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Pengaturan Umum</h2>
<p className="text-sm text-muted-foreground">Informasi dasar aplikasi</p>
<h2 className="text-lg font-semibold text-foreground">General Settings</h2>
<p className="text-sm text-muted-foreground">Basic application information</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Nama Aplikasi
Application Name
</label>
<Input
type="text"
@@ -158,7 +158,7 @@ export function AdminSettings() {
<div>
<label className="block text-sm font-medium text-foreground mb-2">
URL Aplikasi
Application URL
</label>
<Input
type="url"
@@ -187,17 +187,17 @@ export function AdminSettings() {
<Shield className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Fitur & Keamanan</h2>
<p className="text-sm text-muted-foreground">Aktifkan atau nonaktifkan fitur</p>
<h2 className="text-lg font-semibold text-foreground">Features & Security</h2>
<p className="text-sm text-muted-foreground">Enable or disable features</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium text-foreground">Registrasi Pengguna Baru</p>
<p className="font-medium text-foreground">New User Registration</p>
<p className="text-sm text-muted-foreground">
Izinkan pengguna baru mendaftar
Allow new users to register
</p>
</div>
<Switch
@@ -208,9 +208,9 @@ export function AdminSettings() {
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium text-foreground">Verifikasi Email</p>
<p className="font-medium text-foreground">Email Verification</p>
<p className="text-sm text-muted-foreground">
Wajibkan verifikasi email untuk pengguna baru
Require email verification for new users
</p>
</div>
<Switch
@@ -221,9 +221,9 @@ export function AdminSettings() {
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium text-foreground">Verifikasi Pembayaran Manual</p>
<p className="font-medium text-foreground">Manual Payment Verification</p>
<p className="text-sm text-muted-foreground">
Aktifkan verifikasi manual untuk pembayaran
Enable manual verification for payments
</p>
</div>
<Switch
@@ -241,9 +241,9 @@ export function AdminSettings() {
<Database className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Mode Pemeliharaan</h2>
<h2 className="text-lg font-semibold text-foreground">Maintenance Mode</h2>
<p className="text-sm text-muted-foreground">
Nonaktifkan akses sementara untuk maintenance
Temporarily disable access for maintenance
</p>
</div>
</div>
@@ -251,9 +251,9 @@ export function AdminSettings() {
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-destructive/10 border border-destructive/20">
<div>
<p className="font-medium text-foreground">Mode Pemeliharaan</p>
<p className="font-medium text-foreground">Maintenance Mode</p>
<p className="text-sm text-muted-foreground">
Aktifkan untuk menutup akses sementara
Enable to temporarily close access
</p>
</div>
<Switch
@@ -266,7 +266,7 @@ export function AdminSettings() {
{settings.maintenanceMode && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Pesan Pemeliharaan
Maintenance Message
</label>
<Textarea
value={settings.maintenanceMessage}
@@ -286,7 +286,7 @@ export function AdminSettings() {
className="flex items-center gap-2"
>
<Save className="h-5 w-5" />
{saving ? 'Menyimpan...' : 'Simpan Pengaturan'}
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</div>

View File

@@ -0,0 +1,63 @@
import { useState } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Globe, Shield, Wallet } from 'lucide-react'
import { AdminSettingsGeneral } from './settings/AdminSettingsGeneral'
import { AdminSettingsSecurity } from './settings/AdminSettingsSecurity'
import { AdminSettingsPaymentMethods } from './settings/AdminSettingsPaymentMethods'
export function AdminSettings() {
const [activeTab, setActiveTab] = useState('general')
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Settings</h1>
<p className="mt-2 text-muted-foreground">
Manage system configuration and settings
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col md:flex-row gap-6">
{/* Vertical Tabs (Horizontal scrollable on mobile) */}
<TabsList className="flex md:flex-col h-auto md:w-64 flex-shrink-0 bg-muted/50 p-1 overflow-x-auto md:overflow-x-visible justify-start md:h-fit">
<TabsTrigger
value="general"
className="w-full justify-start gap-3 px-4 py-3 data-[state=active]:bg-background whitespace-nowrap"
>
<Globe className="h-5 w-5" />
<span className="inline">General</span>
</TabsTrigger>
<TabsTrigger
value="security"
className="w-full justify-start gap-3 px-4 py-3 data-[state=active]:bg-background whitespace-nowrap"
>
<Shield className="h-5 w-5" />
<span className="inline">Security</span>
</TabsTrigger>
<TabsTrigger
value="payment-methods"
className="w-full justify-start gap-3 px-4 py-3 data-[state=active]:bg-background whitespace-nowrap"
>
<Wallet className="h-5 w-5" />
<span className="inline">Payment Methods</span>
</TabsTrigger>
</TabsList>
{/* Tab Content */}
<div className="flex-1 min-w-0">
<TabsContent value="general" className="mt-0">
<AdminSettingsGeneral />
</TabsContent>
<TabsContent value="security" className="mt-0">
<AdminSettingsSecurity />
</TabsContent>
<TabsContent value="payment-methods" className="mt-0">
<AdminSettingsPaymentMethods />
</TabsContent>
</div>
</Tabs>
</div>
)
}

View File

@@ -1,10 +1,17 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { Search, UserX, UserCheck, Crown } from 'lucide-react'
import { Search, UserX, UserCheck, Crown, Plus, Edit, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
@@ -39,6 +46,9 @@ export function AdminUsers() {
const [suspendReason, setSuspendReason] = useState('')
const [grantProDialog, setGrantProDialog] = useState<{ open: boolean; userId: string }>({ open: false, userId: '' })
const [proDays, setProDays] = useState('')
const [userDialog, setUserDialog] = useState<{ open: boolean; mode: 'create' | 'edit'; user?: User }>({ open: false, mode: 'create' })
const [formData, setFormData] = useState({ email: '', password: '', name: '', role: 'user' })
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; userId: string; userName: string }>({ open: false, userId: '', userName: '' })
useEffect(() => {
fetchUsers()
@@ -76,12 +86,12 @@ export function AdminUsers() {
suspendDialog.suspend ? { reason: suspendReason } : {},
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success(suspendDialog.suspend ? 'User berhasil disuspend' : 'User berhasil diaktifkan kembali')
toast.success(suspendDialog.suspend ? 'User suspended successfully' : 'User unsuspended successfully')
fetchUsers()
setSuspendDialog({ open: false, userId: '', suspend: false })
} catch (error) {
console.error('Failed to update user:', error)
toast.error('Gagal mengupdate user')
toast.error('Failed to update user')
}
}
@@ -103,32 +113,116 @@ export function AdminUsers() {
},
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('Akses Pro berhasil diberikan!')
toast.success('Pro access granted successfully!')
fetchUsers()
setGrantProDialog({ open: false, userId: '' })
} catch (error) {
console.error('Failed to grant pro access:', error)
toast.error('Gagal memberikan akses Pro')
toast.error('Failed to grant Pro access')
}
}
const openUserDialog = (mode: 'create' | 'edit', user?: User) => {
setUserDialog({ open: true, mode, user })
if (mode === 'edit' && user) {
setFormData({
email: user.email,
password: '',
name: user.name || '',
role: user.role,
})
} else {
setFormData({ email: '', password: '', name: '', role: 'user' })
}
}
const handleSaveUser = async () => {
if (!formData.email) {
toast.error('Email is required')
return
}
if (userDialog.mode === 'create' && !formData.password) {
toast.error('Password is required')
return
}
try {
const token = localStorage.getItem('token')
if (userDialog.mode === 'create') {
await axios.post(
`${API_URL}/api/admin/users`,
formData,
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('User created successfully!')
} else {
const updateData: any = {
email: formData.email,
name: formData.name || null,
role: formData.role,
}
await axios.put(
`${API_URL}/api/admin/users/${userDialog.user?.id}`,
updateData,
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('User updated successfully!')
}
fetchUsers()
setUserDialog({ open: false, mode: 'create' })
} catch (error: any) {
console.error('Failed to save user:', error)
const message = error.response?.data?.message || 'Failed to save user'
toast.error(message)
}
}
const openDeleteDialog = (userId: string, userName: string) => {
setDeleteDialog({ open: true, userId, userName })
}
const handleDelete = async () => {
try {
const token = localStorage.getItem('token')
await axios.delete(
`${API_URL}/api/admin/users/${deleteDialog.userId}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('User deleted successfully!')
fetchUsers()
setDeleteDialog({ open: false, userId: '', userName: '' })
} catch (error) {
console.error('Failed to delete user:', error)
toast.error('Failed to delete user')
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Memuat...</div>
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">
Kelola Users
</h1>
<p className="mt-2 text-muted-foreground">
Kelola akun dan izin pengguna
</p>
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">
Manage Users
</h1>
<p className="mt-2 text-muted-foreground">
Manage user accounts and permissions
</p>
</div>
<Button onClick={() => openUserDialog('create')}>
<Plus className="h-4 w-4 mr-2" />
Add User
</Button>
</div>
{/* Search */}
@@ -217,6 +311,13 @@ export function AdminUsers() {
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button
onClick={() => openUserDialog('edit', user)}
className="text-primary hover:text-primary/80"
title="Edit User"
>
<Edit className="h-4 w-4 inline" />
</button>
{user.suspendedAt ? (
<button
onClick={() => openSuspendDialog(user.id, false)}
@@ -241,6 +342,13 @@ export function AdminUsers() {
>
<Crown className="h-4 w-4 inline" />
</button>
<button
onClick={() => openDeleteDialog(user.id, user.name || user.email)}
className="text-destructive hover:text-destructive/80"
title="Delete User"
>
<Trash2 className="h-4 w-4 inline" />
</button>
</td>
</tr>
))}
@@ -251,7 +359,7 @@ export function AdminUsers() {
{users.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">Tidak ada user</p>
<p className="text-muted-foreground">No users found</p>
</div>
)}
@@ -317,6 +425,93 @@ export function AdminUsers() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create/Edit User Dialog */}
<Dialog open={userDialog.open} onOpenChange={(open) => setUserDialog({ ...userDialog, open })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{userDialog.mode === 'create' ? 'Create New User' : 'Edit User'}</DialogTitle>
<DialogDescription>
{userDialog.mode === 'create'
? 'Add a new user to the system.'
: 'Update user information.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="user@example.com"
/>
</div>
{userDialog.mode === 'create' && (
<div className="space-y-2">
<Label htmlFor="password">Password *</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="Enter password"
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Full name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={formData.role} onValueChange={(value) => setFormData({ ...formData, role: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserDialog({ open: false, mode: 'create' })}>
Cancel
</Button>
<Button onClick={handleSaveUser}>
{userDialog.mode === 'create' ? 'Create User' : 'Update User'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogDescription>
Are you sure you want to delete <strong>{deleteDialog.userName}</strong>? This action cannot be undone and will delete all associated data including wallets and transactions.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialog({ open: false, userId: '', userName: '' })}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete User
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,209 @@
import { useState, useEffect } from 'react'
import axios from 'axios'
import { Globe, Database, Save } from 'lucide-react'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
interface GeneralSettings {
appName: string
appUrl: string
supportEmail: string
maintenanceMode: boolean
maintenanceMessage: string
}
export function AdminSettingsGeneral() {
const [settings, setSettings] = useState<GeneralSettings>({
appName: 'Tabungin',
appUrl: 'https://tabungin.app',
supportEmail: 'support@tabungin.app',
maintenanceMode: false,
maintenanceMessage: 'System is under maintenance. Please try again later.',
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
fetchSettings()
}, [])
const fetchSettings = async () => {
try {
setLoading(true)
const token = localStorage.getItem('token')
const response = await axios.get(`${API_URL}/api/admin/config/by-category`, {
headers: { Authorization: `Bearer ${token}` },
})
const configData = response.data
const settingsObj: GeneralSettings = {
appName: configData.general?.find((c: any) => c.key === 'app_name')?.value || 'Tabungin',
appUrl: configData.general?.find((c: any) => c.key === 'app_url')?.value || 'https://tabungin.app',
supportEmail: configData.general?.find((c: any) => c.key === 'support_email')?.value || 'support@tabungin.app',
maintenanceMode: configData.system?.find((c: any) => c.key === 'maintenance_mode')?.value === 'true',
maintenanceMessage: configData.system?.find((c: any) => c.key === 'maintenance_message')?.value || 'System is under maintenance. Please try again later.',
}
setSettings(settingsObj)
} catch (error) {
console.error('Failed to fetch settings:', error)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
try {
setSaving(true)
const token = localStorage.getItem('token')
const configUpdates = [
{ key: 'app_name', value: settings.appName, category: 'general', label: 'Application Name', type: 'text' },
{ key: 'app_url', value: settings.appUrl, category: 'general', label: 'Application URL', type: 'text' },
{ key: 'support_email', value: settings.supportEmail, category: 'general', label: 'Support Email', type: 'email' },
{ key: 'maintenance_mode', value: String(settings.maintenanceMode), category: 'system', label: 'Maintenance Mode', type: 'boolean' },
{ key: 'maintenance_message', value: settings.maintenanceMessage, category: 'system', label: 'Maintenance Message', type: 'text' },
]
await Promise.all(
configUpdates.map((config) =>
axios.post(`${API_URL}/api/admin/config/${config.key}`, config, {
headers: { Authorization: `Bearer ${token}` },
})
)
)
toast.success('Settings saved successfully')
fetchSettings()
} catch (error) {
console.error('Failed to save settings:', error)
toast.error('Failed to save settings')
} finally {
setSaving(false)
}
}
const handleChange = (field: keyof GeneralSettings, value: string | boolean) => {
setSettings((prev) => ({ ...prev, [field]: value }))
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
return (
<div className="space-y-6">
{/* General Settings Card */}
<div className="bg-card rounded-xl border border-border p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-primary/10">
<Globe className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">General Settings</h2>
<p className="text-sm text-muted-foreground">Basic application information</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Application Name
</label>
<Input
type="text"
value={settings.appName}
onChange={(e) => handleChange('appName', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Application URL
</label>
<Input
type="url"
value={settings.appUrl}
onChange={(e) => handleChange('appUrl', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Support Email
</label>
<Input
type="email"
value={settings.supportEmail}
onChange={(e) => handleChange('supportEmail', e.target.value)}
/>
</div>
</div>
</div>
{/* Maintenance Mode Card */}
<div className="bg-card rounded-xl border border-border p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-primary/10">
<Database className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Maintenance Mode</h2>
<p className="text-sm text-muted-foreground">
Temporarily disable access for maintenance
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-destructive/10 border border-destructive/20">
<div>
<p className="font-medium text-foreground">Maintenance Mode</p>
<p className="text-sm text-muted-foreground">
Enable to temporarily close access
</p>
</div>
<Switch
checked={settings.maintenanceMode}
onCheckedChange={(checked) => handleChange('maintenanceMode', checked)}
className="data-[state=checked]:bg-destructive"
/>
</div>
{settings.maintenanceMode && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Maintenance Message
</label>
<Textarea
value={settings.maintenanceMessage}
onChange={(e) => handleChange('maintenanceMessage', e.target.value)}
rows={3}
/>
</div>
)}
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2"
>
<Save className="h-5 w-5" />
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,8 @@
// This component wraps the existing AdminPaymentMethods component
// to be used as a tab content in the Settings page
import { AdminPaymentMethods } from '../AdminPaymentMethods'
export function AdminSettingsPaymentMethods() {
return <AdminPaymentMethods />
}

View File

@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react'
import axios from 'axios'
import { Shield, Save } from 'lucide-react'
import { toast } from 'sonner'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
interface SecuritySettings {
enableRegistration: boolean
enableEmailVerification: boolean
enablePaymentVerification: boolean
}
export function AdminSettingsSecurity() {
const [settings, setSettings] = useState<SecuritySettings>({
enableRegistration: true,
enableEmailVerification: true,
enablePaymentVerification: true,
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
fetchSettings()
}, [])
const fetchSettings = async () => {
try {
setLoading(true)
const token = localStorage.getItem('token')
const response = await axios.get(`${API_URL}/api/admin/config/by-category`, {
headers: { Authorization: `Bearer ${token}` },
})
const configData = response.data
const settingsObj: SecuritySettings = {
enableRegistration: configData.features?.find((c: any) => c.key === 'enable_registration')?.value === 'true',
enableEmailVerification: configData.features?.find((c: any) => c.key === 'enable_email_verification')?.value === 'true',
enablePaymentVerification: configData.features?.find((c: any) => c.key === 'enable_payment_verification')?.value === 'true',
}
setSettings(settingsObj)
} catch (error) {
console.error('Failed to fetch settings:', error)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
try {
setSaving(true)
const token = localStorage.getItem('token')
const configUpdates = [
{ key: 'enable_registration', value: String(settings.enableRegistration), category: 'features', label: 'New User Registration', type: 'boolean' },
{ key: 'enable_email_verification', value: String(settings.enableEmailVerification), category: 'features', label: 'Email Verification', type: 'boolean' },
{ key: 'enable_payment_verification', value: String(settings.enablePaymentVerification), category: 'features', label: 'Payment Verification', type: 'boolean' },
]
await Promise.all(
configUpdates.map((config) =>
axios.post(`${API_URL}/api/admin/config/${config.key}`, config, {
headers: { Authorization: `Bearer ${token}` },
})
)
)
toast.success('Settings saved successfully')
fetchSettings()
} catch (error) {
console.error('Failed to save settings:', error)
toast.error('Failed to save settings')
} finally {
setSaving(false)
}
}
const handleChange = (field: keyof SecuritySettings, value: boolean) => {
setSettings((prev) => ({ ...prev, [field]: value }))
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Feature Toggles Card */}
<div className="bg-card rounded-xl border border-border p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-primary/10">
<Shield className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Features & Security</h2>
<p className="text-sm text-muted-foreground">Enable or disable features</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium text-foreground">New User Registration</p>
<p className="text-sm text-muted-foreground">
Allow new users to register
</p>
</div>
<Switch
checked={settings.enableRegistration}
onCheckedChange={(checked) => handleChange('enableRegistration', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium text-foreground">Email Verification</p>
<p className="text-sm text-muted-foreground">
Require email verification for new users
</p>
</div>
<Switch
checked={settings.enableEmailVerification}
onCheckedChange={(checked) => handleChange('enableEmailVerification', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium text-foreground">Manual Payment Verification</p>
<p className="text-sm text-muted-foreground">
Enable manual verification for payments
</p>
</div>
<Switch
checked={settings.enablePaymentVerification}
onCheckedChange={(checked) => handleChange('enablePaymentVerification', checked)}
/>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2"
>
<Save className="h-5 w-5" />
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</div>
)
}

View File

@@ -1,32 +1,64 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { Loader2 } from 'lucide-react'
import axios from 'axios'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
export function AuthCallback() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { updateUser } = useAuth()
const [error, setError] = useState('')
useEffect(() => {
const token = searchParams.get('token')
if (token) {
// Store token and redirect to dashboard
localStorage.setItem('token', token)
// Force reload to trigger auth context
window.location.href = '/'
} else {
// No token, redirect to login
navigate('/auth/login')
const handleCallback = async () => {
const token = searchParams.get('token')
if (!token) {
navigate('/auth/login')
return
}
try {
// Store token
localStorage.setItem('token', token)
// Fetch user to check role
const response = await axios.get(`${API_URL}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` }
})
// Redirect based on role
if (response.data.role === 'admin') {
window.location.href = '/admin'
} else {
window.location.href = '/'
}
} catch (err) {
console.error('Failed to fetch user:', err)
setError('Failed to complete sign in')
localStorage.removeItem('token')
setTimeout(() => navigate('/auth/login'), 2000)
}
}
}, [searchParams, navigate, updateUser])
handleCallback()
}, [searchParams, navigate])
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-gray-600">Completing sign in...</p>
{error ? (
<>
<p className="text-red-600 mb-4">{error}</p>
<p className="text-gray-600">Redirecting to login...</p>
</>
) : (
<>
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-gray-600">Completing sign in...</p>
</>
)}
</div>
</div>
)

View File

@@ -0,0 +1,60 @@
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface MaintenancePageProps {
message?: string
}
export function MaintenancePage({ message }: MaintenancePageProps) {
const defaultMessage = 'System is under maintenance. Please try again later.'
const handleRefresh = () => {
window.location.reload()
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="max-w-md w-full text-center space-y-6">
{/* Icon */}
<div className="flex justify-center">
<div className="p-6 rounded-full bg-yellow-500/10">
<AlertTriangle className="h-16 w-16 text-yellow-600" />
</div>
</div>
{/* Title */}
<div className="space-y-2">
<h1 className="text-3xl font-bold text-foreground">
Under Maintenance
</h1>
<p className="text-muted-foreground text-lg">
{message || defaultMessage}
</p>
</div>
{/* Description */}
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
We're currently performing scheduled maintenance to improve your experience.
We'll be back online shortly.
</p>
</div>
{/* Refresh Button */}
<Button
onClick={handleRefresh}
className="w-full"
size="lg"
>
<RefreshCw className="h-5 w-5 mr-2" />
Refresh Page
</Button>
{/* Footer */}
<p className="text-xs text-muted-foreground">
Thank you for your patience
</p>
</div>
</div>
)
}

View File

@@ -57,9 +57,13 @@ export function OtpVerification() {
setLoading(true)
try {
await verifyOtp(tempToken, code, method)
// Verification successful, redirect to dashboard
navigate('/')
const result = await verifyOtp(tempToken, code, method)
// Verification successful, redirect based on role
if (result.user?.role === 'admin') {
navigate('/admin')
} else {
navigate('/')
}
} catch (err) {
const error = err as { response?: { data?: { message?: string } } }
setError(error.response?.data?.message || 'Invalid OTP code. Please try again.')

View File

@@ -498,14 +498,14 @@ export function Profile() {
return (
<div className="space-y-6">
<div className="max-w-4xl mx-auto">
<div className="mx-auto">
<h1 className="text-3xl font-bold">{t.profile.title}</h1>
<p className="text-muted-foreground">{t.profile.description}</p>
</div>
<div className="max-w-4xl mx-auto">
<div className="mx-auto">
<Tabs defaultValue="profile" className="w-full">
<TabsList className="grid w-[50%] grid-cols-2 h-auto p-1">
<TabsList className="grid md:w-[40%] grid-cols-2 h-auto p-1">
<TabsTrigger value="profile" className="h-11 md:h-9 text-base md:text-sm data-[state=active]:bg-background">
{t.profile.editProfile}
</TabsTrigger>
@@ -516,6 +516,7 @@ export function Profile() {
{/* Edit Profile Tab */}
<TabsContent value="profile" className="w-full space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<Card>
<CardHeader>
<CardTitle>{t.profile.personalInfo}</CardTitle>
@@ -679,6 +680,7 @@ export function Profile() {
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Security Tab */}
@@ -720,7 +722,7 @@ export function Profile() {
<Input
id="current-password"
type="password"
placeholder="******"
placeholder="••••••"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={passwordLoading}
@@ -733,7 +735,7 @@ export function Profile() {
<Input
id="new-password"
type="password"
placeholder="******"
placeholder="••••••"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={passwordLoading}
@@ -745,7 +747,7 @@ export function Profile() {
<Input
id="confirm-password"
type="password"
placeholder="******"
placeholder="••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={passwordLoading}
@@ -782,6 +784,8 @@ export function Profile() {
</CardHeader>
<CardContent className="space-y-6">
<Separator />
{/* WhatsApp OTP */}
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -794,7 +798,7 @@ export function Profile() {
</p>
</div>
</div>
<Badge variant={otpStatus.whatsappEnabled ? "default" : "secondary"}>
<Badge variant={otpStatus.whatsappEnabled ? "default" : "secondary"} className="text-nowrap">
{otpStatus.whatsappEnabled ? t.profile.enabled : t.profile.disabled}
</Badge>
</div>
@@ -895,7 +899,7 @@ export function Profile() {
</p>
</div>
</div>
<Badge variant={otpStatus.emailEnabled ? "default" : "secondary"}>
<Badge variant={otpStatus.emailEnabled ? "default" : "secondary"} className="text-nowrap">
{otpStatus.emailEnabled ? t.profile.enabled : t.profile.disabled}
</Badge>
</div>
@@ -974,7 +978,7 @@ export function Profile() {
</p>
</div>
</div>
<Badge variant={otpStatus.totpEnabled ? "default" : "secondary"}>
<Badge variant={otpStatus.totpEnabled ? "default" : "secondary"} className="text-nowrap">
{otpStatus.totpEnabled ? t.profile.enabled : t.profile.disabled}
</Badge>
</div>

View File

@@ -0,0 +1,24 @@
import axios from 'axios'
let maintenanceCallback: ((message: string) => void) | null = null
export function setupAxiosInterceptors(onMaintenance: (message: string) => void) {
maintenanceCallback = onMaintenance
// Response interceptor to handle maintenance mode
axios.interceptors.response.use(
(response) => response,
(error) => {
// Check if it's a maintenance mode error (503)
if (error.response?.status === 503 && error.response?.data?.maintenanceMode) {
const message = error.response.data.message || 'System is under maintenance. Please try again later.'
if (maintenanceCallback) {
maintenanceCallback(message)
}
// Prevent further error handling
return Promise.reject({ maintenanceMode: true, message })
}
return Promise.reject(error)
}
)
}