Remove Settings and Setup pages along with their tests and related API services
- Deleted Settings.tsx and Setup.tsx pages, which included functionality for changing passwords and setting up an admin account. - Removed associated test files for Setup page. - Eliminated API service definitions related to proxy hosts, remote servers, import functionality, and health checks. - Cleaned up mock data and test setup files. - Removed configuration files for TypeScript, Vite, and Tailwind CSS. - Deleted scripts for testing coverage, release management, Dockerfile validation, and Python compilation checks. - Removed Sourcery pre-commit wrapper script.
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
import CertificateList from '../components/CertificateList'
|
||||
|
||||
export default function Certificates() {
|
||||
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 automatically acquired by Caddy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CertificateList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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 (err) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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;
|
||||
@@ -1,145 +0,0 @@
|
||||
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 (err) {
|
||||
// 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 (err) {
|
||||
// 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 (err) {
|
||||
// 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>
|
||||
)}
|
||||
|
||||
{!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={Object.keys(preview.preview.conflicts)}
|
||||
errors={preview.preview.errors}
|
||||
onCommit={handleCommit}
|
||||
onCancel={() => setShowReview(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { toast } from '../components/Toast'
|
||||
import client from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await client.post('/auth/login', { email, password })
|
||||
login()
|
||||
toast.success('Logged in successfully')
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.error || 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<Button type="submit" className="w-full" isLoading={loading}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import ProxyHostForm from '../components/ProxyHostForm'
|
||||
|
||||
export default function ProxyHosts() {
|
||||
const { hosts, loading, error, createHost, updateHost, deleteHost } = useProxyHosts()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingHost, setEditingHost] = useState<ProxyHost | undefined>()
|
||||
|
||||
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">
|
||||
<h1 className="text-3xl font-bold text-white">Proxy Hosts</h1>
|
||||
<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}</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">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
host.enabled
|
||||
? 'bg-green-900/30 text-green-400'
|
||||
: 'bg-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{host.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import type { RemoteServer } from '../api/remoteServers'
|
||||
import RemoteServerForm from '../components/RemoteServerForm'
|
||||
|
||||
export default function RemoteServers() {
|
||||
const { servers, loading, 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">
|
||||
<h1 className="text-3xl font-bold text-white">Remote Servers</h1>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { toast } from '../components/Toast'
|
||||
import client from '../api/client'
|
||||
|
||||
export default function Settings() {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error('New passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await client.post('/auth/change-password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
toast.success('Password updated successfully')
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.error || 'Failed to update password')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">Settings</h1>
|
||||
<div className="grid gap-6">
|
||||
<Card title="Change Password" className="max-w-md">
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<Input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={e => setOldPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<Button type="submit" isLoading={loading}>
|
||||
Update Password
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { getSetupStatus, performSetup, SetupRequest } from '../api/setup';
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState<SetupRequest>({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: getSetupStatus,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (status && !status.setupRequired) {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [status, navigate]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: performSetup,
|
||||
onSuccess: () => {
|
||||
navigate('/login');
|
||||
},
|
||||
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="rounded-md shadow-sm -space-y-px">
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Admin User"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="admin@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
This email will be used for Let's Encrypt certificate notifications and recovery.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="********"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
'Create Admin Account'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
@@ -1,127 +0,0 @@
|
||||
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 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',
|
||||
}, expect.anything());
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
|
||||
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