chore: remove cashed
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../api/user'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import { Copy, RefreshCw, Shield, Mail, User, AlertTriangle } from 'lucide-react'
|
||||
import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter'
|
||||
import { isValidEmail } from '../utils/validation'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export default function Account() {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Profile State
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailValid, setEmailValid] = useState<boolean | null>(null)
|
||||
const [confirmPasswordForUpdate, setConfirmPasswordForUpdate] = useState('')
|
||||
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false)
|
||||
const [pendingProfileUpdate, setPendingProfileUpdate] = useState<{name: string, email: string} | null>(null)
|
||||
const [previousEmail, setPreviousEmail] = useState('')
|
||||
const [showEmailConfirmModal, setShowEmailConfirmModal] = useState(false)
|
||||
|
||||
// Certificate Email State
|
||||
const [certEmail, setCertEmail] = useState('')
|
||||
const [certEmailValid, setCertEmailValid] = useState<boolean | null>(null)
|
||||
const [useUserEmail, setUseUserEmail] = useState(true)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { changePassword } = useAuth()
|
||||
|
||||
const { data: profile, isLoading: isLoadingProfile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: getProfile,
|
||||
})
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
// Initialize profile state
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setName(profile.name)
|
||||
setEmail(profile.email)
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
// Validate profile email
|
||||
useEffect(() => {
|
||||
if (email) {
|
||||
setEmailValid(isValidEmail(email))
|
||||
} else {
|
||||
setEmailValid(null)
|
||||
}
|
||||
}, [email])
|
||||
|
||||
// Initialize cert email state
|
||||
useEffect(() => {
|
||||
if (settings && profile) {
|
||||
const savedEmail = settings['caddy.email']
|
||||
if (savedEmail && savedEmail !== profile.email) {
|
||||
setCertEmail(savedEmail)
|
||||
setUseUserEmail(false)
|
||||
} else {
|
||||
setCertEmail(profile.email)
|
||||
setUseUserEmail(true)
|
||||
}
|
||||
}
|
||||
}, [settings, profile])
|
||||
|
||||
// Validate cert email
|
||||
useEffect(() => {
|
||||
if (certEmail && !useUserEmail) {
|
||||
setCertEmailValid(isValidEmail(certEmail))
|
||||
} else {
|
||||
setCertEmailValid(null)
|
||||
}
|
||||
}, [certEmail, useUserEmail])
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: updateProfile,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
toast.success('Profile updated successfully')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to update profile: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const updateSettingMutation = useMutation({
|
||||
mutationFn: (variables: { key: string; value: string; category: string }) =>
|
||||
updateSetting(variables.key, variables.value, variables.category),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success('Certificate email updated')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to update certificate email: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: regenerateApiKey,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
toast.success('API Key regenerated successfully')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to regenerate API key: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!emailValid) return
|
||||
|
||||
// Check if email changed
|
||||
if (email !== profile?.email) {
|
||||
setPreviousEmail(profile?.email || '')
|
||||
setPendingProfileUpdate({ name, email })
|
||||
setShowPasswordPrompt(true)
|
||||
return
|
||||
}
|
||||
|
||||
updateProfileMutation.mutate({ name, email })
|
||||
}
|
||||
|
||||
const handlePasswordPromptSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!pendingProfileUpdate) return
|
||||
|
||||
setShowPasswordPrompt(false)
|
||||
|
||||
// If email changed, we might need to ask about cert email too
|
||||
// But first, let's update the profile with the password
|
||||
updateProfileMutation.mutate({
|
||||
name: pendingProfileUpdate.name,
|
||||
email: pendingProfileUpdate.email,
|
||||
current_password: confirmPasswordForUpdate
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setConfirmPasswordForUpdate('')
|
||||
// Check if we need to prompt for cert email
|
||||
// We do this AFTER success to ensure profile is updated
|
||||
// But wait, if we do it after success, the profile email is already new.
|
||||
// The user wanted to be asked.
|
||||
// Let's ask about cert email first? No, user said "Updateing email test the popup worked as expected"
|
||||
// But "I chose to keep my certificate email as the old email and it changed anyway"
|
||||
// This implies the logic below is flawed or the backend/frontend sync is weird.
|
||||
|
||||
// Let's show the cert email modal if the update was successful AND it was an email change
|
||||
setShowEmailConfirmModal(true)
|
||||
},
|
||||
onError: () => {
|
||||
setConfirmPasswordForUpdate('')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const confirmEmailUpdate = (updateCertEmail: boolean) => {
|
||||
setShowEmailConfirmModal(false)
|
||||
|
||||
if (updateCertEmail) {
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: email,
|
||||
category: 'caddy'
|
||||
})
|
||||
setCertEmail(email)
|
||||
setUseUserEmail(true)
|
||||
} else {
|
||||
// If user chose NO, we must ensure the cert email stays as the OLD email.
|
||||
// If settings['caddy.email'] is empty, it defaults to profile email (which is now NEW).
|
||||
// So we must explicitly save the OLD email.
|
||||
const savedEmail = settings?.['caddy.email']
|
||||
if (!savedEmail && previousEmail) {
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: previousEmail,
|
||||
category: 'caddy'
|
||||
})
|
||||
// Update local state immediately
|
||||
setCertEmail(previousEmail)
|
||||
setUseUserEmail(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateCertEmail = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!useUserEmail && !certEmailValid) return
|
||||
|
||||
const emailToSave = useUserEmail ? profile?.email : certEmail
|
||||
if (!emailToSave) return
|
||||
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: emailToSave,
|
||||
category: 'caddy'
|
||||
})
|
||||
}
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error('New passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await changePassword(oldPassword, newPassword)
|
||||
toast.success('Password updated successfully')
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update password')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyApiKey = () => {
|
||||
if (profile?.api_key) {
|
||||
navigator.clipboard.writeText(profile.api_key)
|
||||
toast.success('API Key copied to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingProfile) {
|
||||
return <div className="p-4">Loading profile...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Account Settings</h1>
|
||||
|
||||
{/* Profile Settings */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<User className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Profile</h2>
|
||||
</div>
|
||||
<form onSubmit={handleUpdateProfile} className="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
error={emailValid === false ? 'Please enter a valid email address' : undefined}
|
||||
className={emailValid === true ? 'border-green-500 focus:ring-green-500' : ''}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={updateProfileMutation.isPending} disabled={emailValid === false}>
|
||||
Save Profile
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Certificate Email Settings */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Mail className="w-5 h-5 text-purple-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Certificate Email</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
This email is used for Let's Encrypt notifications and recovery.
|
||||
</p>
|
||||
<form onSubmit={handleUpdateCertEmail} className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useUserEmail"
|
||||
checked={useUserEmail}
|
||||
onChange={(e) => {
|
||||
setUseUserEmail(e.target.checked)
|
||||
if (e.target.checked && profile) {
|
||||
setCertEmail(profile.email)
|
||||
}
|
||||
}}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="useUserEmail" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Use my account email ({profile?.email})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!useUserEmail && (
|
||||
<Input
|
||||
label="Custom Email"
|
||||
type="email"
|
||||
value={certEmail}
|
||||
onChange={(e) => setCertEmail(e.target.value)}
|
||||
required={!useUserEmail}
|
||||
error={certEmailValid === false ? 'Please enter a valid email address' : undefined}
|
||||
className={certEmailValid === true ? 'border-green-500 focus:ring-green-500' : ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={updateSettingMutation.isPending} disabled={!useUserEmail && certEmailValid === false}>
|
||||
Save Certificate Email
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Password Change */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Change Password</h2>
|
||||
</div>
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
<Input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
</div>
|
||||
<Input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
error={confirmPassword && newPassword !== confirmPassword ? 'Passwords do not match' : undefined}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={loading}>
|
||||
Update Password
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* API Key */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-1 bg-yellow-100 dark:bg-yellow-900/30 rounded">
|
||||
<span className="text-lg">🔑</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">API Key</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Use this key to authenticate with the API programmatically. Keep it secret!
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={profile?.api_key || ''}
|
||||
readOnly
|
||||
className="font-mono text-sm bg-gray-50 dark:bg-gray-900"
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={copyApiKey} title="Copy to clipboard">
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
isLoading={regenerateMutation.isPending}
|
||||
title="Regenerate API Key"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Password Prompt Modal */}
|
||||
{showPasswordPrompt && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4 text-blue-600 dark:text-blue-500">
|
||||
<Shield className="w-6 h-6" />
|
||||
<h3 className="text-lg font-bold">Confirm Password</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Please enter your current password to confirm these changes.
|
||||
</p>
|
||||
<form onSubmit={handlePasswordPromptSubmit} className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Current Password"
|
||||
value={confirmPasswordForUpdate}
|
||||
onChange={(e) => setConfirmPasswordForUpdate(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button type="submit" className="w-full" isLoading={updateProfileMutation.isPending}>
|
||||
Confirm & Update
|
||||
</Button>
|
||||
<Button type="button" onClick={() => {
|
||||
setShowPasswordPrompt(false)
|
||||
setConfirmPasswordForUpdate('')
|
||||
setPendingProfileUpdate(null)
|
||||
}} variant="ghost" className="w-full text-gray-500">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Update Confirmation Modal */}
|
||||
{showEmailConfirmModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4 text-yellow-600 dark:text-yellow-500">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
<h3 className="text-lg font-bold">Update Certificate Email?</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
You are changing your account email to <strong>{email}</strong>.
|
||||
Do you want to use this new email for SSL certificates as well?
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button onClick={() => confirmEmailUpdate(true)} className="w-full">
|
||||
Yes, update certificate email too
|
||||
</Button>
|
||||
<Button onClick={() => confirmEmailUpdate(false)} variant="secondary" className="w-full">
|
||||
No, keep using {previousEmail || certEmail}
|
||||
</Button>
|
||||
<Button onClick={() => setShowEmailConfirmModal(false)} variant="ghost" className="w-full text-gray-500">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getBackups, createBackup, restoreBackup, deleteBackup, BackupFile } from '../api/backups'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import { Loader2, Download, RotateCcw, Plus, Archive, Trash2, Save } from 'lucide-react'
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
export default function Backups() {
|
||||
const queryClient = useQueryClient()
|
||||
const [interval, setInterval] = useState('7')
|
||||
const [retention, setRetention] = useState('30')
|
||||
|
||||
// Fetch Backups
|
||||
const { data: backups, isLoading: isLoadingBackups } = useQuery({
|
||||
queryKey: ['backups'],
|
||||
queryFn: getBackups,
|
||||
})
|
||||
|
||||
// Fetch Settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
// Update local state when settings load
|
||||
useState(() => {
|
||||
if (settings) {
|
||||
if (settings['backup.interval']) setInterval(settings['backup.interval'])
|
||||
if (settings['backup.retention']) setRetention(settings['backup.retention'])
|
||||
}
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createBackup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] })
|
||||
toast.success('Backup created successfully')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to create backup: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: restoreBackup,
|
||||
onSuccess: () => {
|
||||
toast.success('Backup restored successfully. Please restart the container.')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to restore backup: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteBackup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] })
|
||||
toast.success('Backup deleted successfully')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to delete backup: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const saveSettingsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await updateSetting('backup.interval', interval, 'system', 'int')
|
||||
await updateSetting('backup.retention', retention, 'system', 'int')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success('Backup settings saved')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to save settings: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleDownload = (filename: string) => {
|
||||
// Trigger download via browser navigation
|
||||
// The browser will send the auth cookie automatically
|
||||
window.location.href = `/api/v1/backups/${filename}/download`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Archive className="w-8 h-8" />
|
||||
Backups
|
||||
</h1>
|
||||
|
||||
{/* Settings Section */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Configuration</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<Input
|
||||
label="Backup Interval (Days)"
|
||||
type="number"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
<Input
|
||||
label="Retention Period (Days)"
|
||||
type="number"
|
||||
value={retention}
|
||||
onChange={(e) => setRetention(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveSettingsMutation.mutate()}
|
||||
isLoading={saveSettingsMutation.isPending}
|
||||
className="mb-0.5"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => createMutation.mutate()} isLoading={createMutation.isPending}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Backup
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-medium">Filename</th>
|
||||
<th className="px-6 py-3 font-medium">Size</th>
|
||||
<th className="px-6 py-3 font-medium">Created At</th>
|
||||
<th className="px-6 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{isLoadingBackups ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-8 text-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto text-blue-500" />
|
||||
</td>
|
||||
</tr>
|
||||
) : backups?.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
|
||||
No backups found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
backups?.map((backup: BackupFile) => (
|
||||
<tr key={backup.filename} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white">
|
||||
{backup.filename}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
|
||||
{formatSize(backup.size)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
|
||||
{new Date(backup.time).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(backup.filename)}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to restore this backup? Current data will be overwritten.')) {
|
||||
restoreMutation.mutate(backup.filename)
|
||||
}
|
||||
}}
|
||||
isLoading={restoreMutation.isPending}
|
||||
title="Restore"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this backup?')) {
|
||||
deleteMutation.mutate(backup.filename)
|
||||
}
|
||||
}}
|
||||
isLoading={deleteMutation.isPending}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import CertificateList from '../components/CertificateList'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { uploadCertificate } from '../api/certificates'
|
||||
import { toast } from '../utils/toast'
|
||||
|
||||
export default function Certificates() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [certFile, setCertFile] = useState<File | null>(null)
|
||||
const [keyFile, setKeyFile] = useState<File | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!certFile || !keyFile) throw new Error('Files required')
|
||||
await uploadCertificate(name, certFile, keyFile)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
setIsModalOpen(false)
|
||||
setName('')
|
||||
setCertFile(null)
|
||||
setKeyFile(null)
|
||||
toast.success('Certificate uploaded successfully')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to upload certificate: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
uploadMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Certificates</h1>
|
||||
<p className="text-gray-400">
|
||||
View and manage SSL certificates.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Certificate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CertificateList />
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-white">Upload Certificate</h2>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Friendly Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. My Custom Cert"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Certificate (PEM)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pem,.crt,.cer"
|
||||
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
|
||||
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Private Key (PEM)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pem,.key"
|
||||
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
|
||||
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={uploadMutation.isPending}>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import { checkHealth } from '../api/health'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { hosts } = useProxyHosts()
|
||||
const { servers } = useRemoteServers()
|
||||
const [health, setHealth] = useState<{ status: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHealth = async () => {
|
||||
try {
|
||||
const result = await checkHealth()
|
||||
setHealth(result)
|
||||
} catch {
|
||||
setHealth({ status: 'error' })
|
||||
}
|
||||
}
|
||||
fetchHealth()
|
||||
}, [])
|
||||
|
||||
const enabledHosts = hosts.filter(h => h.enabled).length
|
||||
const enabledServers = servers.filter(s => s.enabled).length
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">Dashboard</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<Link to="/proxy-hosts" className="bg-dark-card p-6 rounded-lg border border-gray-800 hover:border-gray-700 transition-colors">
|
||||
<div className="text-sm text-gray-400 mb-2">Proxy Hosts</div>
|
||||
<div className="text-3xl font-bold text-white mb-1">{hosts.length}</div>
|
||||
<div className="text-xs text-gray-500">{enabledHosts} enabled</div>
|
||||
</Link>
|
||||
|
||||
<Link to="/remote-servers" className="bg-dark-card p-6 rounded-lg border border-gray-800 hover:border-gray-700 transition-colors">
|
||||
<div className="text-sm text-gray-400 mb-2">Remote Servers</div>
|
||||
<div className="text-3xl font-bold text-white mb-1">{servers.length}</div>
|
||||
<div className="text-xs text-gray-500">{enabledServers} enabled</div>
|
||||
</Link>
|
||||
|
||||
<div className="bg-dark-card p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-sm text-gray-400 mb-2">SSL Certificates</div>
|
||||
<div className="text-3xl font-bold text-white mb-1">0</div>
|
||||
<div className="text-xs text-gray-500">Coming soon</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-card p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-sm text-gray-400 mb-2">System Status</div>
|
||||
<div className={`text-lg font-bold ${health?.status === 'ok' ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{health?.status === 'ok' ? 'Healthy' : health ? 'Error' : 'Checking...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Link
|
||||
to="/proxy-hosts"
|
||||
className="flex items-center gap-3 p-4 bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="text-2xl">🌐</span>
|
||||
<div>
|
||||
<div className="font-medium text-white">Add Proxy Host</div>
|
||||
<div className="text-xs text-gray-400">Create a new reverse proxy</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/remote-servers"
|
||||
className="flex items-center gap-3 p-4 bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="text-2xl">🖥️</span>
|
||||
<div>
|
||||
<div className="font-medium text-white">Add Remote Server</div>
|
||||
<div className="text-xs text-gray-400">Register a backend server</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/import"
|
||||
className="flex items-center gap-3 p-4 bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="text-2xl">📥</span>
|
||||
<div>
|
||||
<div className="font-medium text-white">Import Caddyfile</div>
|
||||
<div className="text-xs text-gray-400">Bulk import from existing config</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react'
|
||||
import { useDomains } from '../hooks/useDomains'
|
||||
import { Trash2, Plus, Globe, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function Domains() {
|
||||
const { domains, isLoading, isFetching, error, createDomain, deleteDomain } = useDomains()
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newDomain.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createDomain(newDomain)
|
||||
setNewDomain('')
|
||||
} catch (err) {
|
||||
alert('Failed to create domain')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm('Are you sure you want to delete this domain?')) {
|
||||
try {
|
||||
await deleteDomain(uuid)
|
||||
} catch (err) {
|
||||
alert('Failed to delete domain')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-8 text-white">Loading...</div>
|
||||
if (error) return <div className="p-8 text-red-400">Error loading domains</div>
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">Domains</h1>
|
||||
{isFetching && !isLoading && <Loader2 className="animate-spin text-blue-400" size={24} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Add New Domain Card */}
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-white mb-4 flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Add Domain
|
||||
</h3>
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
Domain Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !newDomain.trim()}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add Domain'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Domain List */}
|
||||
{domains.map((domain) => (
|
||||
<div key={domain.uuid} className="bg-dark-card border border-gray-800 rounded-lg p-6 flex flex-col justify-between">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-900/30 rounded-lg text-blue-400">
|
||||
<Globe size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-white">{domain.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Added {new Date(domain.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(domain.uuid)}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||
title="Delete Domain"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import client from '../api/client';
|
||||
|
||||
interface HealthResponse {
|
||||
status: string;
|
||||
service: string;
|
||||
}
|
||||
|
||||
const fetchHealth = async (): Promise<HealthResponse> => {
|
||||
const { data } = await client.get<HealthResponse>('/health');
|
||||
return data;
|
||||
};
|
||||
|
||||
const HealthStatus = () => {
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['health'], queryFn: fetchHealth });
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>System Status</h2>
|
||||
{isLoading && <p>Checking health…</p>}
|
||||
{isError && <p className="error">Unable to reach backend</p>}
|
||||
{data && (
|
||||
<ul>
|
||||
<li>Service: {data.service}</li>
|
||||
<li>Status: {data.status}</li>
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthStatus;
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState } from 'react'
|
||||
import { useImport } from '../hooks/useImport'
|
||||
import ImportBanner from '../components/ImportBanner'
|
||||
import ImportReviewTable from '../components/ImportReviewTable'
|
||||
|
||||
export default function ImportCaddy() {
|
||||
const { session, preview, loading, error, upload, commit, cancel } = useImport()
|
||||
const [content, setContent] = useState('')
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!content.trim()) {
|
||||
alert('Please enter Caddyfile content')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await upload(content)
|
||||
setShowReview(true)
|
||||
} catch {
|
||||
// Error is already set by hook
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const text = await file.text()
|
||||
setContent(text)
|
||||
}
|
||||
|
||||
const handleCommit = async (resolutions: Record<string, string>) => {
|
||||
try {
|
||||
await commit(resolutions)
|
||||
setContent('')
|
||||
setShowReview(false)
|
||||
alert('Import completed successfully!')
|
||||
} catch {
|
||||
// Error is already set by hook
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (confirm('Are you sure you want to cancel this import?')) {
|
||||
try {
|
||||
await cancel()
|
||||
setShowReview(false)
|
||||
} catch {
|
||||
// Error is already set by hook
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">Import Caddyfile</h1>
|
||||
|
||||
{session && (
|
||||
<ImportBanner
|
||||
session={session}
|
||||
onReview={() => setShowReview(true)}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show warning if preview is empty but session exists (e.g. mounted file was empty or invalid) */}
|
||||
{session && preview && preview.preview && preview.preview.hosts.length === 0 && (
|
||||
<div className="bg-yellow-900/20 border border-yellow-500 text-yellow-400 px-4 py-3 rounded mb-6">
|
||||
<p className="font-bold">No domains found in Caddyfile</p>
|
||||
<p className="text-sm mt-1">
|
||||
The imported file appears to be empty or contains no valid reverse_proxy directives.
|
||||
Please check the file content below.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!session && (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Upload or Paste Caddyfile</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Import an existing Caddyfile to automatically create proxy host configurations.
|
||||
The system will detect conflicts and allow you to review changes before committing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Upload Caddyfile
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".caddyfile,.txt,text/plain"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-active file:text-white hover:file:bg-blue-hover file:cursor-pointer cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Or Divider */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 border-t border-gray-700" />
|
||||
<span className="text-gray-500 text-sm">or paste content</span>
|
||||
<div className="flex-1 border-t border-gray-700" />
|
||||
</div>
|
||||
|
||||
{/* Text Area */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Caddyfile Content
|
||||
</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
className="w-full h-96 bg-gray-900 border border-gray-700 rounded-lg p-4 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={`example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={loading || !content.trim()}
|
||||
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Processing...' : 'Parse and Review'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showReview && preview && preview.preview && (
|
||||
<ImportReviewTable
|
||||
hosts={preview.preview.hosts}
|
||||
conflicts={preview.preview.conflicts}
|
||||
errors={preview.preview.errors}
|
||||
caddyfileContent={preview.caddyfile_content}
|
||||
onCommit={handleCommit}
|
||||
onCancel={() => setShowReview(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { toast } from '../utils/toast'
|
||||
import client from '../api/client'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { getSetupStatus } from '../api/setup'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showResetInfo, setShowResetInfo] = useState(false)
|
||||
const { login } = useAuth()
|
||||
|
||||
const { data: setupStatus, isLoading: isCheckingSetup } = useQuery({
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: getSetupStatus,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (setupStatus?.setupRequired) {
|
||||
navigate('/setup')
|
||||
}
|
||||
}, [setupStatus, navigate])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await client.post('/auth/login', { email, password })
|
||||
await login()
|
||||
await queryClient.invalidateQueries({ queryKey: ['setupStatus'] })
|
||||
toast.success('Logged in successfully')
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.error || 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isCheckingSetup) {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<div className="text-white">Checking setup status...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md" title="Login">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResetInfo(!showResetInfo)}
|
||||
className="text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Forgot Password?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showResetInfo && (
|
||||
<div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4 text-sm text-blue-200">
|
||||
<p className="mb-2 font-medium">To reset your password:</p>
|
||||
<p className="mb-2">Run this command on your server:</p>
|
||||
<code className="block bg-black/50 p-2 rounded font-mono text-xs break-all select-all">
|
||||
docker exec -it caddy-proxy-manager /app/backend reset-password <email> <new-password>
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={loading}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getLogs, getLogContent, downloadLog, LogFilter } from '../api/logs';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Loader2, FileText, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { LogTable } from '../components/LogTable';
|
||||
import { LogFilters } from '../components/LogFilters';
|
||||
import { Button } from '../components/ui/Button';
|
||||
|
||||
const Logs: React.FC = () => {
|
||||
const [selectedLog, setSelectedLog] = useState<string | null>(null);
|
||||
|
||||
// Filter State
|
||||
const [search, setSearch] = useState('');
|
||||
const [host, setHost] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [sort, setSort] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
const { data: logs, isLoading: isLoadingLogs } = useQuery({
|
||||
queryKey: ['logs'],
|
||||
queryFn: getLogs,
|
||||
});
|
||||
|
||||
// Select first log by default if none selected
|
||||
React.useEffect(() => {
|
||||
if (!selectedLog && logs && logs.length > 0) {
|
||||
setSelectedLog(logs[0].name);
|
||||
}
|
||||
}, [logs, selectedLog]);
|
||||
|
||||
const filter: LogFilter = {
|
||||
search,
|
||||
host,
|
||||
status,
|
||||
level,
|
||||
limit,
|
||||
offset: page * limit,
|
||||
sort
|
||||
};
|
||||
|
||||
const { data: logData, isLoading: isLoadingContent, refetch: refetchContent } = useQuery({
|
||||
queryKey: ['logContent', selectedLog, search, host, status, level, page, sort],
|
||||
queryFn: () => selectedLog ? getLogContent(selectedLog, filter) : Promise.resolve(null),
|
||||
enabled: !!selectedLog,
|
||||
});
|
||||
|
||||
const handleDownload = () => {
|
||||
if (selectedLog) {
|
||||
downloadLog(selectedLog);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = logData ? Math.ceil(logData.total / limit) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Access Logs</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Log File List */}
|
||||
<div className="md:col-span-1 space-y-4">
|
||||
<Card className="p-4">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Log Files</h2>
|
||||
{isLoadingLogs ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{logs?.map((log) => (
|
||||
<button
|
||||
key={log.name}
|
||||
onClick={() => { setSelectedLog(log.name); setPage(0); }}
|
||||
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors flex items-center ${
|
||||
selectedLog === log.name
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
<div className="flex-1 truncate">
|
||||
<div className="font-medium">{log.name}</div>
|
||||
<div className="text-xs text-gray-500">{(log.size / 1024 / 1024).toFixed(2)} MB</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
<div className="text-sm text-gray-500 text-center py-4">No log files found</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Log Content */}
|
||||
<div className="md:col-span-3 space-y-4">
|
||||
{selectedLog ? (
|
||||
<>
|
||||
<LogFilters
|
||||
search={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(0); }}
|
||||
host={host}
|
||||
onHostChange={(v) => { setHost(v); setPage(0); }}
|
||||
status={status}
|
||||
onStatusChange={(v) => { setStatus(v); setPage(0); }}
|
||||
level={level}
|
||||
onLevelChange={(v) => { setLevel(v); setPage(0); }}
|
||||
sort={sort}
|
||||
onSortChange={(v) => { setSort(v); setPage(0); }}
|
||||
onRefresh={refetchContent}
|
||||
onDownload={handleDownload}
|
||||
isLoading={isLoadingContent}
|
||||
/>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<LogTable
|
||||
logs={logData?.logs || []}
|
||||
isLoading={isLoadingContent}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{logData && logData.total > 0 && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing {logData.offset + 1} to {Math.min(logData.offset + limit, logData.total)} of {logData.total} entries
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Page</span>
|
||||
<select
|
||||
value={page}
|
||||
onChange={(e) => setPage(Number(e.target.value))}
|
||||
className="block w-20 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white py-1"
|
||||
disabled={isLoadingContent}
|
||||
>
|
||||
{Array.from({ length: totalPages }, (_, i) => (
|
||||
<option key={i} value={i}>
|
||||
{i + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">of {totalPages}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0 || isLoadingContent}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= totalPages - 1 || isLoadingContent}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-8 flex flex-col items-center justify-center text-gray-500 h-64">
|
||||
<FileText className="w-12 h-12 mb-4 opacity-20" />
|
||||
<p>Select a log file to view contents</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
@@ -0,0 +1,309 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, NotificationProvider } from '../api/notifications';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
const ProviderForm: React.FC<{
|
||||
initialData?: Partial<NotificationProvider>;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
}> = ({ initialData, onClose, onSubmit }) => {
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({
|
||||
defaultValues: initialData || {
|
||||
type: 'discord',
|
||||
enabled: true,
|
||||
config: '',
|
||||
notify_proxy_hosts: true,
|
||||
notify_remote_servers: true,
|
||||
notify_domains: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: true
|
||||
}
|
||||
});
|
||||
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: testProvider,
|
||||
onSuccess: () => {
|
||||
setTestStatus('success');
|
||||
setTimeout(() => setTestStatus('idle'), 3000);
|
||||
},
|
||||
onError: () => {
|
||||
setTestStatus('error');
|
||||
setTimeout(() => setTestStatus('idle'), 3000);
|
||||
}
|
||||
});
|
||||
|
||||
const handleTest = () => {
|
||||
const formData = watch();
|
||||
testMutation.mutate(formData as any);
|
||||
};
|
||||
|
||||
const type = watch('type');
|
||||
|
||||
const setTemplate = (template: string) => {
|
||||
setValue('config', template);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
|
||||
<input
|
||||
{...register('name', { required: 'Name is required' })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
|
||||
/>
|
||||
{errors.name && <span className="text-red-500 text-xs">{errors.name.message as string}</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Type</label>
|
||||
<select
|
||||
{...register('type')}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
|
||||
>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="gotify">Gotify</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="generic">Generic Webhook (Shoutrrr)</option>
|
||||
<option value="webhook">Custom Webhook (JSON)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">URL / Webhook</label>
|
||||
<input
|
||||
{...register('url', { required: 'URL is required' })}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
|
||||
/>
|
||||
{type !== 'webhook' && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
For Shoutrrr format, see <a href="https://containrrr.dev/shoutrrr/" target="_blank" rel="noreferrer" className="text-blue-500 hover:underline">documentation</a>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{type === 'webhook' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">JSON Payload Template</label>
|
||||
<div className="flex gap-2 mb-2 mt-1">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => setTemplate('{"content": "{{.Title}}: {{.Message}}"}')}>
|
||||
Simple Template
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => setTemplate(`{
|
||||
"embeds": [{
|
||||
"title": "{{.Title}}",
|
||||
"description": "{{.Message}}",
|
||||
"color": 15158332,
|
||||
"fields": [
|
||||
{ "name": "Monitor", "value": "{{.Name}}", "inline": true },
|
||||
{ "name": "Status", "value": "{{.Status}}", "inline": true },
|
||||
{ "name": "Latency", "value": "{{.Latency}}ms", "inline": true }
|
||||
]
|
||||
}]
|
||||
}`)}>
|
||||
Detailed Template (Discord)
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
{...register('config')}
|
||||
rows={8}
|
||||
className="mt-1 block w-full font-mono text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
placeholder='{"text": "{{.Message}}"}'
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Available variables: .Title, .Message, .Status, .Name, .Latency, .Time
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Notification Events</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_proxy_hosts')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Proxy Hosts</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_remote_servers')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Remote Servers</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_domains')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Domains</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_certs')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Certificates</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_uptime')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Uptime</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('enabled')}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-900 dark:text-gray-300">Enabled</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleTest}
|
||||
disabled={testMutation.isPending}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{testMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> :
|
||||
testStatus === 'success' ? <Check className="w-4 h-4 text-green-500 mx-auto" /> :
|
||||
testStatus === 'error' ? <X className="w-4 h-4 text-red-500 mx-auto" /> :
|
||||
"Test"}
|
||||
</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const Notifications: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const { data: providers, isLoading } = useQuery({
|
||||
queryKey: ['notificationProviders'],
|
||||
queryFn: getProviders,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createProvider,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
|
||||
setIsAdding(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => updateProvider(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
|
||||
setEditingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProvider,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: testProvider,
|
||||
onSuccess: () => alert('Test notification sent!'),
|
||||
onError: (err: any) => alert(`Failed to send test: ${err.response?.data?.error || err.message}`),
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Bell className="w-6 h-6" />
|
||||
Notification Providers
|
||||
</h1>
|
||||
<Button onClick={() => setIsAdding(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<Card className="p-6 mb-6 border-blue-500 border-2">
|
||||
<h3 className="text-lg font-medium mb-4">Add New Provider</h3>
|
||||
<ProviderForm
|
||||
onClose={() => setIsAdding(false)}
|
||||
onSubmit={(data) => createMutation.mutate(data)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{providers?.map((provider) => (
|
||||
<Card key={provider.id} className="p-4">
|
||||
{editingId === provider.id ? (
|
||||
<ProviderForm
|
||||
initialData={provider}
|
||||
onClose={() => setEditingId(null)}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: provider.id, data })}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-2 rounded-full ${provider.enabled ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'}`}>
|
||||
<Bell className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">{provider.name}</h3>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<span className="uppercase text-xs font-bold bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{provider.type}
|
||||
</span>
|
||||
<span className="truncate max-w-xs opacity-50">{provider.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => testMutation.mutate(provider)}
|
||||
isLoading={testMutation.isPending}
|
||||
title="Send Test Notification"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditingId(provider.id)}>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure?')) deleteMutation.mutate(provider.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{providers?.length === 0 && !isAdding && (
|
||||
<div className="text-center py-12 text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700">
|
||||
No notification providers configured.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2, ExternalLink } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { getSettings } from '../api/settings'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import ProxyHostForm from '../components/ProxyHostForm'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
|
||||
export default function ProxyHosts() {
|
||||
const { hosts, loading, isFetching, error, createHost, updateHost, deleteHost } = useProxyHosts()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingHost, setEditingHost] = useState<ProxyHost | undefined>()
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
const linkBehavior = settings?.['ui.domain_link_behavior'] || 'new_tab'
|
||||
|
||||
const handleDomainClick = (e: React.MouseEvent, url: string) => {
|
||||
if (linkBehavior === 'new_window') {
|
||||
e.preventDefault()
|
||||
window.open(url, '_blank', 'noopener,noreferrer,width=1024,height=768')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingHost(undefined)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEdit = (host: ProxyHost) => {
|
||||
setEditingHost(host)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: Partial<ProxyHost>) => {
|
||||
if (editingHost) {
|
||||
await updateHost(editingHost.uuid, data)
|
||||
} else {
|
||||
await createHost(data)
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingHost(undefined)
|
||||
}
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm('Are you sure you want to delete this proxy host?')) {
|
||||
try {
|
||||
await deleteHost(uuid)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to delete')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">Proxy Hosts</h1>
|
||||
{isFetching && !loading && <Loader2 className="animate-spin text-blue-400" size={24} />}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Add Proxy Host
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 py-12">Loading...</div>
|
||||
) : hosts.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
No proxy hosts configured yet. Click "Add Proxy Host" to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-900 border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Domain
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Forward To
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
SSL
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{hosts.map((host) => (
|
||||
<tr key={host.uuid} className="hover:bg-gray-900/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-white">
|
||||
{host.domain_names.split(',').map((domain, i) => {
|
||||
const url = `${host.ssl_forced ? 'https' : 'http'}://${domain.trim()}`
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<a
|
||||
href={url}
|
||||
target={linkBehavior === 'same_tab' ? '_self' : '_blank'}
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => handleDomainClick(e, url)}
|
||||
className="hover:text-blue-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
{domain.trim()}
|
||||
<ExternalLink size={12} className="opacity-50" />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-300">
|
||||
{host.forward_scheme}://{host.forward_host}:{host.forward_port}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex gap-2">
|
||||
{host.ssl_forced && (
|
||||
<span className="px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
|
||||
SSL
|
||||
</span>
|
||||
)}
|
||||
{host.websocket_support && (
|
||||
<span className="px-2 py-1 text-xs bg-blue-900/30 text-blue-400 rounded">
|
||||
WS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={host.enabled}
|
||||
onCheckedChange={(checked) => updateHost(host.uuid, { enabled: checked })}
|
||||
/>
|
||||
<span className={`text-sm ${host.enabled ? 'text-green-400' : 'text-gray-400'}`}>
|
||||
{host.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(host)}
|
||||
className="text-blue-400 hover:text-blue-300 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(host.uuid)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<ProxyHostForm
|
||||
host={editingHost}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setEditingHost(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import type { RemoteServer } from '../api/remoteServers'
|
||||
import RemoteServerForm from '../components/RemoteServerForm'
|
||||
|
||||
export default function RemoteServers() {
|
||||
const { servers, loading, isFetching, error, createServer, updateServer, deleteServer } = useRemoteServers()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingServer, setEditingServer] = useState<RemoteServer | undefined>()
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingServer(undefined)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEdit = (server: RemoteServer) => {
|
||||
setEditingServer(server)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: Partial<RemoteServer>) => {
|
||||
if (editingServer) {
|
||||
await updateServer(editingServer.uuid, data)
|
||||
} else {
|
||||
await createServer(data)
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingServer(undefined)
|
||||
}
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm('Are you sure you want to delete this remote server?')) {
|
||||
try {
|
||||
await deleteServer(uuid)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to delete')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">Remote Servers</h1>
|
||||
{isFetching && !loading && <Loader2 className="animate-spin text-blue-400" size={24} />}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex bg-gray-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-blue-active text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
viewMode === 'list'
|
||||
? 'bg-blue-active text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Add Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 py-12">Loading...</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
No remote servers configured. Add servers to quickly select backends when creating proxy hosts.
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
key={server.uuid}
|
||||
className="bg-dark-card rounded-lg border border-gray-800 p-6 hover:border-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">{server.name}</h3>
|
||||
<span className="inline-block px-2 py-1 text-xs bg-gray-800 text-gray-400 rounded">
|
||||
{server.provider}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
server.enabled
|
||||
? 'bg-green-900/30 text-green-400'
|
||||
: 'bg-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{server.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Host:</span>
|
||||
<span className="text-white font-mono">{server.host}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Port:</span>
|
||||
<span className="text-white font-mono">{server.port}</span>
|
||||
</div>
|
||||
{server.username && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">User:</span>
|
||||
<span className="text-white font-mono">{server.username}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4 border-t border-gray-800">
|
||||
<button
|
||||
onClick={() => handleEdit(server)}
|
||||
className="flex-1 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(server.uuid)}
|
||||
className="flex-1 px-3 py-2 bg-red-900/20 hover:bg-red-900/30 text-red-400 text-sm rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-900 border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Provider
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Host
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Port
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{servers.map((server) => (
|
||||
<tr key={server.uuid} className="hover:bg-gray-900/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-white">{server.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs bg-gray-800 text-gray-400 rounded">
|
||||
{server.provider}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-300 font-mono">{server.host}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-300 font-mono">{server.port}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
server.enabled
|
||||
? 'bg-green-900/30 text-green-400'
|
||||
: 'bg-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{server.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(server)}
|
||||
className="text-blue-400 hover:text-blue-300 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(server.uuid)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<RemoteServerForm
|
||||
server={editingServer}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setEditingServer(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
|
||||
export default function Settings() {
|
||||
const location = useLocation()
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">Settings</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Manage system and account settings</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
to="/settings/system"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive('/settings/system')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
System
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings/account"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive('/settings/account')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
Account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-dark-card border border-gray-200 dark:border-gray-800 rounded-md p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getSetupStatus, performSetup, SetupRequest } from '../api/setup';
|
||||
import client from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter';
|
||||
import { isValidEmail } from '../utils/validation';
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const [formData, setFormData] = useState<SetupRequest>({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emailValid, setEmailValid] = useState<boolean | null>(null);
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: getSetupStatus,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.email) {
|
||||
setEmailValid(isValidEmail(formData.email));
|
||||
} else {
|
||||
setEmailValid(null);
|
||||
}
|
||||
}, [formData.email]);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for setup status to load
|
||||
if (statusLoading) return;
|
||||
|
||||
// If setup is required, stay on this page (ignore stale auth)
|
||||
if (status?.setupRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If setup is NOT required, redirect based on auth
|
||||
if (isAuthenticated) {
|
||||
navigate('/');
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [status, statusLoading, isAuthenticated, navigate]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: SetupRequest) => {
|
||||
// 1. Perform Setup
|
||||
await performSetup(data);
|
||||
// 2. Auto Login
|
||||
await client.post('/auth/login', { email: data.email, password: data.password });
|
||||
// 3. Update Auth Context
|
||||
await login();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['setupStatus'] });
|
||||
navigate('/');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.error || 'Setup failed');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate(formData);
|
||||
};
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-blue-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status && !status.setupRequired) {
|
||||
return null; // Will redirect in useEffect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Welcome to CPM+
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Create your administrator account to get started.
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Admin User"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
label="Email Address"
|
||||
type="email"
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className={emailValid === false ? 'border-red-500 focus:ring-red-500' : emailValid === true ? 'border-green-500 focus:ring-green-500' : ''}
|
||||
/>
|
||||
{emailValid === false && (
|
||||
<p className="mt-1 text-xs text-red-500">Please enter a valid email address</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
/>
|
||||
<PasswordStrengthMeter password={formData.password} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={mutation.isPending}
|
||||
>
|
||||
Create Admin Account
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import client from '../api/client'
|
||||
import { Loader2, Server, RefreshCw, Save, Activity } from 'lucide-react'
|
||||
|
||||
interface HealthResponse {
|
||||
status: string
|
||||
service: string
|
||||
version: string
|
||||
git_commit: string
|
||||
build_time: string
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
current_version: string
|
||||
latest_version: string
|
||||
update_available: boolean
|
||||
release_url?: string
|
||||
}
|
||||
|
||||
export default function SystemSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const [caddyAdminAPI, setCaddyAdminAPI] = useState('http://localhost:2019')
|
||||
const [sslProvider, setSslProvider] = useState('letsencrypt')
|
||||
const [domainLinkBehavior, setDomainLinkBehavior] = useState('new_tab')
|
||||
|
||||
// Fetch Settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
// Update local state when settings load
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
if (settings['caddy.admin_api']) setCaddyAdminAPI(settings['caddy.admin_api'])
|
||||
if (settings['caddy.ssl_provider']) setSslProvider(settings['caddy.ssl_provider'])
|
||||
if (settings['ui.domain_link_behavior']) setDomainLinkBehavior(settings['ui.domain_link_behavior'])
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
// Fetch Health/System Status
|
||||
const { data: health, isLoading: isLoadingHealth } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: async (): Promise<HealthResponse> => {
|
||||
const response = await client.get<HealthResponse>('/health')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
// Check for Updates
|
||||
const {
|
||||
data: updateInfo,
|
||||
refetch: checkUpdates,
|
||||
isFetching: isCheckingUpdates,
|
||||
} = useQuery({
|
||||
queryKey: ['updates'],
|
||||
queryFn: async (): Promise<UpdateInfo> => {
|
||||
const response = await client.get<UpdateInfo>('/system/updates')
|
||||
return response.data
|
||||
},
|
||||
enabled: false, // Manual trigger
|
||||
})
|
||||
|
||||
const saveSettingsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await updateSetting('caddy.admin_api', caddyAdminAPI, 'caddy', 'string')
|
||||
await updateSetting('caddy.ssl_provider', sslProvider, 'caddy', 'string')
|
||||
await updateSetting('ui.domain_link_behavior', domainLinkBehavior, 'ui', 'string')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success('System settings saved')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to save settings: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Server className="w-8 h-8" />
|
||||
System Settings
|
||||
</h1>
|
||||
|
||||
{/* General Configuration */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">General Configuration</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Caddy Admin API Endpoint"
|
||||
type="text"
|
||||
value={caddyAdminAPI}
|
||||
onChange={(e) => setCaddyAdminAPI(e.target.value)}
|
||||
placeholder="http://localhost:2019"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 -mt-2">
|
||||
URL to the Caddy admin API (usually on port 2019)
|
||||
</p>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
SSL Provider
|
||||
</label>
|
||||
<select
|
||||
value={sslProvider}
|
||||
onChange={(e) => setSslProvider(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="letsencrypt">Let's Encrypt (Default)</option>
|
||||
<option value="zerossl">ZeroSSL</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Choose the default Certificate Authority for SSL certificates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Domain Link Behavior
|
||||
</label>
|
||||
<select
|
||||
value={domainLinkBehavior}
|
||||
onChange={(e) => setDomainLinkBehavior(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="same_tab">Same Tab</option>
|
||||
<option value="new_tab">New Tab (Default)</option>
|
||||
<option value="new_window">New Window</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Control how domain links open in the Proxy Hosts list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => saveSettingsMutation.mutate()}
|
||||
isLoading={saveSettingsMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* System Status */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-5 h-5" />
|
||||
System Status
|
||||
</h2>
|
||||
{isLoadingHealth ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : health ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Service</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">{health.service}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Status</p>
|
||||
<p className="text-lg font-medium text-green-600 dark:text-green-400 capitalize">
|
||||
{health.status}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Version</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">{health.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Build Time</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{health.build_time || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Git Commit</p>
|
||||
<p className="text-sm font-mono text-gray-900 dark:text-white">
|
||||
{health.git_commit || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-red-500">Unable to fetch system status</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Update Check */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Software Updates</h2>
|
||||
<div className="space-y-4">
|
||||
{updateInfo && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Current Version</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{updateInfo.current_version}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Latest Version</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{updateInfo.latest_version}
|
||||
</p>
|
||||
</div>
|
||||
{updateInfo.update_available && (
|
||||
<div className="md:col-span-2">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-blue-800 dark:text-blue-300 font-medium">
|
||||
A new version is available!
|
||||
</p>
|
||||
{updateInfo.release_url && (
|
||||
<a
|
||||
href={updateInfo.release_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline text-sm"
|
||||
>
|
||||
View Release Notes
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!updateInfo.update_available && (
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-green-600 dark:text-green-400">
|
||||
✓ You are running the latest version
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => checkUpdates()}
|
||||
isLoading={isCheckingUpdates}
|
||||
variant="secondary"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Check for Updates
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
|
||||
export default function Tasks() {
|
||||
const location = useLocation()
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">Tasks</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Manage system tasks and view logs</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
to="/tasks/backups"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive('/tasks/backups')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
Backups
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/tasks/logs"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive('/tasks/logs')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
Logs
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-dark-card border border-gray-200 dark:border-gray-800 rounded-md p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getMonitors, getMonitorHistory } from '../api/uptime';
|
||||
import { Activity, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const MonitorCard: React.FC<{ monitor: any }> = ({ monitor }) => {
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ['uptimeHistory', monitor.id],
|
||||
queryFn: () => getMonitorHistory(monitor.id, 60),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const isUp = monitor.status === 'up';
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 border-l-4 ${isUp ? 'border-l-green-500' : 'border-l-red-500'}`}>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">{monitor.name}</h3>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<a href={`http://${monitor.url}`} target="_blank" rel="noreferrer" className="hover:underline">
|
||||
{monitor.url}
|
||||
</a>
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-xs">
|
||||
{monitor.type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
||||
isUp ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}>
|
||||
{isUp ? <ArrowUp className="w-4 h-4 mr-1" /> : <ArrowDown className="w-4 h-4 mr-1" />}
|
||||
{monitor.status.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Latency</div>
|
||||
<div className="text-lg font-mono font-medium text-gray-900 dark:text-white">
|
||||
{monitor.latency}ms
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Check</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{monitor.last_check ? formatDistanceToNow(new Date(monitor.last_check), { addSuffix: true }) : 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat Bar (Last 60 checks / 1 Hour) */}
|
||||
<div className="flex gap-[2px] h-8 items-end" title="Last 60 checks (1 Hour)">
|
||||
{/* Fill with empty bars if not enough history to keep alignment right-aligned */}
|
||||
{Array.from({ length: Math.max(0, 60 - (history?.length || 0)) }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-sm h-full opacity-50" />
|
||||
))}
|
||||
|
||||
{history?.slice().reverse().map((beat: any, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-sm transition-all duration-200 hover:scale-110 ${
|
||||
beat.status === 'up'
|
||||
? 'bg-green-400 dark:bg-green-500 hover:bg-green-300'
|
||||
: 'bg-red-400 dark:bg-red-500 hover:bg-red-300'
|
||||
}`}
|
||||
style={{ height: '100%' }}
|
||||
title={`${new Date(beat.created_at).toLocaleString()}
|
||||
Status: ${beat.status.toUpperCase()}
|
||||
Latency: ${beat.latency}ms
|
||||
Message: ${beat.message}`}
|
||||
/>
|
||||
))}
|
||||
{(!history || history.length === 0) && (
|
||||
<div className="absolute w-full text-center text-xs text-gray-400">No history available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Uptime: React.FC = () => {
|
||||
const { data: monitors, isLoading } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: getMonitors,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center">Loading monitors...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-6 h-6" />
|
||||
Uptime Monitoring
|
||||
</h1>
|
||||
<div className="text-sm text-gray-500">
|
||||
Auto-refreshing every 30s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{monitors?.map((monitor) => (
|
||||
<MonitorCard key={monitor.id} monitor={monitor} />
|
||||
))}
|
||||
{monitors?.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 text-gray-500">
|
||||
No monitors found. Add a Proxy Host to start monitoring.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Uptime;
|
||||
@@ -0,0 +1,146 @@
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import Setup from '../Setup';
|
||||
import * as setupApi from '../../api/setup';
|
||||
|
||||
// Mock AuthContext so useAuth works in tests
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
get: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/setup', () => ({
|
||||
getSetupStatus: vi.fn(),
|
||||
performSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Setup Page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('renders setup form when setup is required', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to CPM+')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Name')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Email Address')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Password')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not render form when setup is not required', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Welcome to CPM+')).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form successfully', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
vi.mocked(setupApi.performSetup).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to CPM+')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Admin' } });
|
||||
fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'admin@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Admin Account' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setupApi.performSetup).toHaveBeenCalledWith({
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error on submission failure', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
vi.mocked(setupApi.performSetup).mockRejectedValue({
|
||||
response: { data: { error: 'Setup failed' } }
|
||||
});
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to CPM+')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Admin' } });
|
||||
fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'admin@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Admin Account' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Setup failed')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user