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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
63
apps/web/src/components/admin/pages/AdminSettingsNew.tsx
Normal file
63
apps/web/src/components/admin/pages/AdminSettingsNew.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
60
apps/web/src/components/pages/MaintenancePage.tsx
Normal file
60
apps/web/src/components/pages/MaintenancePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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.')
|
||||
|
||||
@@ -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>
|
||||
|
||||
24
apps/web/src/utils/axiosSetup.ts
Normal file
24
apps/web/src/utils/axiosSetup.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user