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:
Wikid82
2025-11-19 22:53:32 -05:00
parent 1bc6be10a1
commit 1e2d87755d
178 changed files with 0 additions and 27250 deletions

View File

@@ -1,71 +0,0 @@
import { useCertificates } from '../hooks/useCertificates'
import { LoadingSpinner } from './LoadingStates'
export default function CertificateList() {
const { certificates, isLoading, error } = useCertificates()
if (isLoading) return <LoadingSpinner />
if (error) return <div className="text-red-500">Failed to load certificates</div>
return (
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-gray-400">
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
<tr>
<th className="px-6 py-3">Domain</th>
<th className="px-6 py-3">Issuer</th>
<th className="px-6 py-3">Expires</th>
<th className="px-6 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{certificates.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
No certificates found.
</td>
</tr>
) : (
certificates.map((cert) => (
<tr key={cert.domain} className="hover:bg-gray-800/50 transition-colors">
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
<td className="px-6 py-4">{cert.issuer}</td>
<td className="px-6 py-4">
{new Date(cert.expires_at).toLocaleDateString()}
</td>
<td className="px-6 py-4">
<StatusBadge status={cert.status} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const styles = {
valid: 'bg-green-900/30 text-green-400 border-green-800',
expiring: 'bg-yellow-900/30 text-yellow-400 border-yellow-800',
expired: 'bg-red-900/30 text-red-400 border-red-800',
}
const labels = {
valid: 'Valid',
expiring: 'Expiring Soon',
expired: 'Expired',
}
const style = styles[status as keyof typeof styles] || styles.valid
const label = labels[status as keyof typeof labels] || status
return (
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${style}`}>
{label}
</span>
)
}

View File

@@ -1,30 +0,0 @@
interface Props {
session: { id: string }
onReview: () => void
onCancel: () => void
}
export default function ImportBanner({ session, onReview, onCancel }: Props) {
return (
<div className="bg-yellow-900/20 border border-yellow-600 text-yellow-300 px-4 py-3 rounded mb-6 flex items-center justify-between">
<div>
<div className="font-medium">Pending Import Session</div>
<div className="text-sm text-yellow-400/80">Session ID: {session.id}</div>
</div>
<div className="flex gap-3">
<button
onClick={onReview}
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-500 text-black rounded text-sm font-medium"
>
Review Changes
</button>
<button
onClick={onCancel}
className="px-3 py-1 bg-gray-800 hover:bg-gray-700 text-yellow-300 border border-yellow-700 rounded text-sm font-medium"
>
Cancel
</button>
</div>
</div>
)
}

View File

@@ -1,121 +0,0 @@
import { useState } from 'react'
interface HostPreview {
domain_names: string
[key: string]: unknown
}
interface Props {
hosts: HostPreview[]
conflicts: string[]
errors: string[]
onCommit: (resolutions: Record<string, string>) => Promise<void>
onCancel: () => void
}
export default function ImportReviewTable({ hosts, conflicts, errors, onCommit, onCancel }: Props) {
const [resolutions, setResolutions] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {}
conflicts.forEach((d: string) => { init[d] = 'keep' })
return init
})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleCommit = async () => {
setSubmitting(true)
setError(null)
try {
await onCommit(resolutions)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to commit import')
} finally {
setSubmitting(false)
}
}
return (
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white">Review Imported Hosts</h2>
<div className="flex gap-3">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
>
Back
</button>
<button
onClick={handleCommit}
disabled={submitting}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{submitting ? 'Committing...' : 'Commit Import'}
</button>
</div>
</div>
{error && (
<div className="m-4 bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
{errors?.length > 0 && (
<div className="m-4 bg-yellow-900/20 border border-yellow-600 text-yellow-300 px-4 py-3 rounded">
<div className="font-medium mb-2">Issues found during parsing</div>
<ul className="list-disc list-inside text-sm">
{errors.map((e, i) => (
<li key={i}>{e}</li>
))}
</ul>
</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 Names
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Conflict Resolution
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{hosts.map((h, idx) => {
const domain = h.domain_names
const hasConflict = conflicts.includes(domain)
return (
<tr key={`${domain}-${idx}`} className="hover:bg-gray-900/50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-white">{domain}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{hasConflict ? (
<select
value={resolutions[domain]}
onChange={e => setResolutions({ ...resolutions, [domain]: e.target.value })}
className="bg-gray-900 border border-gray-700 text-white rounded px-2 py-1"
>
<option value="keep">Keep Existing</option>
<option value="overwrite">Overwrite</option>
<option value="skip">Skip</option>
</select>
) : (
<span className="px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
No conflict
</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -1,88 +0,0 @@
import { ReactNode, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { ThemeToggle } from './ThemeToggle'
import { Button } from './ui/Button'
interface LayoutProps {
children: ReactNode
}
export default function Layout({ children }: LayoutProps) {
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
const navigation = [
{ name: 'Dashboard', path: '/', icon: '📊' },
{ name: 'Proxy Hosts', path: '/proxy-hosts', icon: '🌐' },
{ name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' },
{ name: 'Certificates', path: '/certificates', icon: '🔒' },
{ name: 'Import Caddyfile', path: '/import', icon: '📥' },
{ name: 'Settings', path: '/settings', icon: '⚙️' },
]
return (
<div className="min-h-screen bg-gray-50 dark:bg-dark-bg flex transition-colors duration-200">
{/* Mobile Header */}
<div className="lg:hidden fixed top-0 left-0 right-0 h-16 bg-white dark:bg-dark-sidebar border-b border-gray-200 dark:border-gray-800 flex items-center justify-between px-4 z-40">
<h1 className="text-lg font-bold text-gray-900 dark:text-white">CPM+</h1>
<div className="flex items-center gap-2">
<ThemeToggle />
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(!sidebarOpen)}>
{sidebarOpen ? '✕' : '☰'}
</Button>
</div>
</div>
{/* Sidebar */}
<aside className={`
fixed lg:static inset-y-0 left-0 z-30 w-64 transform transition-transform duration-200 ease-in-out
bg-white dark:bg-dark-sidebar border-r border-gray-200 dark:border-gray-800 flex flex-col
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<div className="p-6 hidden lg:flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">CPM+</h1>
<ThemeToggle />
</div>
<nav className="flex-1 px-4 space-y-1 mt-16 lg:mt-0">
{navigation.map((item) => {
const isActive = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-blue-100 text-blue-700 dark:bg-blue-active dark:text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
}`}
>
<span className="text-lg">{item.icon}</span>
{item.name}
</Link>
)
})}
</nav>
<div className="p-4 border-t border-gray-200 dark:border-gray-800">
<div className="text-xs text-gray-500 dark:text-gray-500">
Version 0.1.0
</div>
</div>
</aside>
{/* Overlay for mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<main className="flex-1 overflow-auto pt-16 lg:pt-0">
{children}
</main>
</div>
)
}

View File

@@ -1,60 +0,0 @@
export function LoadingSpinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-8 h-8 border-3',
lg: 'w-12 h-12 border-4',
}
return (
<div
className={`${sizeClasses[size]} border-blue-600 border-t-transparent rounded-full animate-spin`}
role="status"
aria-label="Loading"
/>
)
}
export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) {
return (
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-slate-800 rounded-lg p-6 flex flex-col items-center gap-4 shadow-xl">
<LoadingSpinner size="lg" />
<p className="text-slate-300">{message}</p>
</div>
</div>
)
}
export function LoadingCard() {
return (
<div className="bg-slate-800 rounded-lg p-6 animate-pulse">
<div className="h-6 bg-slate-700 rounded w-1/3 mb-4"></div>
<div className="space-y-3">
<div className="h-4 bg-slate-700 rounded w-full"></div>
<div className="h-4 bg-slate-700 rounded w-5/6"></div>
<div className="h-4 bg-slate-700 rounded w-4/6"></div>
</div>
</div>
)
}
export function EmptyState({
icon = '📦',
title,
description,
action,
}: {
icon?: string
title: string
description: string
action?: React.ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="text-6xl mb-4">{icon}</div>
<h3 className="text-xl font-semibold text-slate-200 mb-2">{title}</h3>
<p className="text-slate-400 mb-6 max-w-md">{description}</p>
{action}
</div>
)
}

View File

@@ -1,251 +0,0 @@
import { useState } from 'react'
import type { ProxyHost } from '../api/proxyHosts'
import { useRemoteServers } from '../hooks/useRemoteServers'
interface ProxyHostFormProps {
host?: ProxyHost
onSubmit: (data: Partial<ProxyHost>) => Promise<void>
onCancel: () => void
}
export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) {
const [formData, setFormData] = useState({
domain_names: host?.domain_names || '',
forward_scheme: host?.forward_scheme || 'http',
forward_host: host?.forward_host || '',
forward_port: host?.forward_port || 80,
ssl_forced: host?.ssl_forced ?? false,
http2_support: host?.http2_support ?? false,
hsts_enabled: host?.hsts_enabled ?? false,
hsts_subdomains: host?.hsts_subdomains ?? false,
block_exploits: host?.block_exploits ?? true,
websocket_support: host?.websocket_support ?? false,
advanced_config: host?.advanced_config || '',
enabled: host?.enabled ?? true,
})
const { servers: remoteServers } = useRemoteServers()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
await onSubmit(formData)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save proxy host')
} finally {
setLoading(false)
}
}
const handleServerSelect = (serverUuid: string) => {
const server = remoteServers.find(s => s.uuid === serverUuid)
if (server) {
setFormData({
...formData,
forward_host: server.host,
forward_port: server.port,
forward_scheme: 'http',
})
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-800">
<h2 className="text-2xl font-bold text-white">
{host ? 'Edit Proxy Host' : 'Add Proxy Host'}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
{/* Domain Names */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Domain Names (comma-separated)
</label>
<input
type="text"
required
value={formData.domain_names}
onChange={e => setFormData({ ...formData, domain_names: e.target.value })}
placeholder="example.com, www.example.com"
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"
/>
</div>
{/* Remote Server Quick Select */}
{remoteServers.length > 0 && (
<div>
<label htmlFor="quick-select" className="block text-sm font-medium text-gray-300 mb-2">
Quick Select from Remote Servers
</label>
<select
id="quick-select"
onChange={e => handleServerSelect(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"
>
<option value="">-- Select a server --</option>
{remoteServers.map(server => (
<option key={server.uuid} value={server.uuid}>
{server.name} ({server.host}:{server.port})
</option>
))}
</select>
</div>
)}
{/* Forward Details */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Scheme</label>
<select
value={formData.forward_scheme}
onChange={e => setFormData({ ...formData, forward_scheme: 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"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Host</label>
<input
type="text"
required
value={formData.forward_host}
onChange={e => setFormData({ ...formData, forward_host: e.target.value })}
placeholder="192.168.1.100"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Port</label>
<input
type="number"
required
min="1"
max="65535"
value={formData.forward_port}
onChange={e => setFormData({ ...formData, forward_port: parseInt(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"
/>
</div>
</div>
{/* SSL & Security Options */}
<div className="space-y-3">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.ssl_forced}
onChange={e => setFormData({ ...formData, ssl_forced: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Force SSL</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.http2_support}
onChange={e => setFormData({ ...formData, http2_support: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HTTP/2 Support</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.hsts_enabled}
onChange={e => setFormData({ ...formData, hsts_enabled: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HSTS Enabled</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.hsts_subdomains}
onChange={e => setFormData({ ...formData, hsts_subdomains: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HSTS Subdomains</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.block_exploits}
onChange={e => setFormData({ ...formData, block_exploits: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Block Common Exploits</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.websocket_support}
onChange={e => setFormData({ ...formData, websocket_support: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">WebSocket Support</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.enabled}
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Enabled</span>
</label>
</div>
{/* Advanced Config */}
<div>
<label htmlFor="advanced-config" className="block text-sm font-medium text-gray-300 mb-2">
Advanced Caddy Config (Optional)
</label>
<textarea
id="advanced-config"
value={formData.advanced_config}
onChange={e => setFormData({ ...formData, advanced_config: e.target.value })}
placeholder="Additional Caddy directives..."
rows={4}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Actions */}
<div className="flex gap-3 justify-end pt-4 border-t border-gray-800">
<button
type="button"
onClick={onCancel}
disabled={loading}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : (host ? 'Update' : 'Create')}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,180 +0,0 @@
import { useEffect, useState } from 'react'
import type { RemoteServer } from '../api/remoteServers'
import { remoteServersAPI } from '../services/api'
interface Props {
server?: RemoteServer
onSubmit: (data: Partial<RemoteServer>) => Promise<void>
onCancel: () => void
}
export default function RemoteServerForm({ server, onSubmit, onCancel }: Props) {
const [formData, setFormData] = useState({
name: server?.name || '',
provider: server?.provider || 'generic',
host: server?.host || '',
port: server?.port ?? 22,
username: server?.username || '',
enabled: server?.enabled ?? true,
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setFormData({
name: server?.name || '',
provider: server?.provider || 'generic',
host: server?.host || '',
port: server?.port ?? 22,
username: server?.username || '',
enabled: server?.enabled ?? true,
})
}, [server])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
await onSubmit(formData)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save server')
} finally {
setLoading(false)
}
}
const handleTestConnection = async () => {
if (!server?.uuid) return
setLoading(true)
setError(null)
try {
const result = await remoteServersAPI.test(server.uuid)
alert(`Connection successful: ${result.address}`)
} catch (err) {
setError('Connection failed')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-lg w-full">
<div className="p-6 border-b border-gray-800">
<h2 className="text-2xl font-bold text-white">
{server ? 'Edit Remote Server' : 'Add Remote Server'}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Name</label>
<input
type="text"
required
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="My Production Server"
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Provider</label>
<select
value={formData.provider}
onChange={e => setFormData({ ...formData, provider: 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"
>
<option value="generic">Generic</option>
<option value="docker">Docker</option>
<option value="kubernetes">Kubernetes</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Host</label>
<input
type="text"
required
value={formData.host}
onChange={e => setFormData({ ...formData, host: e.target.value })}
placeholder="192.168.1.100"
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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Port</label>
<input
type="number"
min={1}
max={65535}
value={formData.port}
onChange={e => setFormData({ ...formData, port: parseInt(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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Username</label>
<input
type="text"
value={formData.username}
onChange={e => setFormData({ ...formData, username: 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"
/>
</div>
</div>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.enabled}
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Enabled</span>
</label>
<div className="flex gap-3 justify-end pt-4 border-t border-gray-800">
{server && (
<button
type="button"
onClick={handleTestConnection}
disabled={loading}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 mr-auto"
>
Test Connection
</button>
)}
<button
type="button"
onClick={onCancel}
disabled={loading}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : (server ? 'Update' : 'Create')}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,20 +0,0 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const RequireAuth: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return <div>Loading...</div>; // Or a spinner
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
export default RequireAuth;

View File

@@ -1,38 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getSetupStatus } from '../api/setup';
interface SetupGuardProps {
children: React.ReactNode;
}
export const SetupGuard: React.FC<SetupGuardProps> = ({ children }) => {
const navigate = useNavigate();
const { data: status, isLoading } = useQuery({
queryKey: ['setupStatus'],
queryFn: getSetupStatus,
retry: false,
});
useEffect(() => {
if (status?.setupRequired) {
navigate('/setup');
}
}, [status, navigate]);
if (isLoading) {
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?.setupRequired) {
return null; // Will redirect
}
return <>{children}</>;
};

View File

@@ -1,12 +0,0 @@
import { useTheme } from '../context/ThemeContext'
import { Button } from './ui/Button'
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<Button variant="ghost" size="sm" onClick={toggleTheme} title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
{theme === 'dark' ? '☀️' : '🌙'}
</Button>
)
}

View File

@@ -1,86 +0,0 @@
import { useEffect, useState } from 'react'
type ToastType = 'success' | 'error' | 'info' | 'warning'
interface Toast {
id: number
message: string
type: ToastType
}
let toastId = 0
const toastCallbacks = new Set<(toast: Toast) => void>()
export const toast = {
success: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'success' }))
},
error: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'error' }))
},
info: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'info' }))
},
warning: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'warning' }))
},
}
export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([])
useEffect(() => {
const callback = (toast: Toast) => {
setToasts(prev => [...prev, toast])
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== toast.id))
}, 5000)
}
toastCallbacks.add(callback)
return () => {
toastCallbacks.delete(callback)
}
}, [])
const removeToast = (id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
{toasts.map(toast => (
<div
key={toast.id}
className={`pointer-events-auto px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] animate-slide-in ${
toast.type === 'success'
? 'bg-green-600 text-white'
: toast.type === 'error'
? 'bg-red-600 text-white'
: toast.type === 'warning'
? 'bg-yellow-600 text-white'
: 'bg-blue-600 text-white'
}`}
>
<div className="flex-1">
{toast.type === 'success' && <span className="mr-2"></span>}
{toast.type === 'error' && <span className="mr-2"></span>}
{toast.type === 'warning' && <span className="mr-2"></span>}
{toast.type === 'info' && <span className="mr-2"></span>}
{toast.message}
</div>
<button
onClick={() => removeToast(toast.id)}
className="text-white/80 hover:text-white transition-colors"
aria-label="Close"
>
×
</button>
</div>
))}
</div>
)
}

View File

@@ -1,118 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import ImportReviewTable from '../ImportReviewTable'
import { mockImportPreview } from '../../test/mockData'
describe('ImportReviewTable', () => {
const mockOnCommit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
afterEach(() => {
vi.clearAllMocks()
})
it('displays hosts to import', () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Review Imported Hosts')).toBeInTheDocument()
expect(screen.getByText('test.example.com')).toBeInTheDocument()
})
it('displays conflicts with resolution dropdowns', () => {
const conflicts = ['test.example.com']
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('test.example.com')).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('displays errors', () => {
const errors = ['Invalid Caddyfile syntax', 'Missing required field']
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
errors={errors}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Issues found during parsing')).toBeInTheDocument()
expect(screen.getByText('Invalid Caddyfile syntax')).toBeInTheDocument()
expect(screen.getByText('Missing required field')).toBeInTheDocument()
})
it('calls onCommit with resolutions', async () => {
const conflicts = ['test.example.com']
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const dropdown = screen.getByRole('combobox')
fireEvent.change(dropdown, { target: { value: 'overwrite' } })
const commitButton = screen.getByText('Commit Import')
fireEvent.click(commitButton)
await waitFor(() => {
expect(mockOnCommit).toHaveBeenCalledWith({
'test.example.com': 'overwrite',
})
})
})
it('calls onCancel when cancel button is clicked', () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
fireEvent.click(screen.getByText('Back'))
expect(mockOnCancel).toHaveBeenCalledOnce()
})
it('shows conflict indicator on conflicting hosts', () => {
const conflicts = ['test.example.com']
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByRole('combobox')).toBeInTheDocument()
expect(screen.queryByText('No conflict')).not.toBeInTheDocument()
})
})

View File

@@ -1,63 +0,0 @@
import { ReactNode } from 'react'
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import Layout from '../Layout'
import { ThemeProvider } from '../../context/ThemeContext'
const renderWithProviders = (children: ReactNode) => {
return render(
<BrowserRouter>
<ThemeProvider>
{children}
</ThemeProvider>
</BrowserRouter>
)
}
describe('Layout', () => {
it('renders the application title', () => {
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
expect(screen.getAllByText('CPM+')[0]).toBeInTheDocument()
})
it('renders all navigation items', () => {
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
expect(screen.getByText('Certificates')).toBeInTheDocument()
expect(screen.getByText('Import Caddyfile')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
})
it('renders children content', () => {
renderWithProviders(
<Layout>
<div data-testid="test-content">Test Content</div>
</Layout>
)
expect(screen.getByTestId('test-content')).toBeInTheDocument()
})
it('displays version information', () => {
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
expect(screen.getByText('Version 0.1.0')).toBeInTheDocument()
})
})

View File

@@ -1,202 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ProxyHostForm from '../ProxyHostForm'
import { mockRemoteServers } from '../../test/mockData'
// Mock the hook
vi.mock('../../hooks/useRemoteServers', () => ({
useRemoteServers: vi.fn(() => ({
servers: mockRemoteServers,
isLoading: false,
error: null,
createRemoteServer: vi.fn(),
updateRemoteServer: vi.fn(),
deleteRemoteServer: vi.fn(),
})),
}))
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const renderWithClient = (ui: React.ReactElement) => {
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
)
}
describe('ProxyHostForm', () => {
const mockOnSubmit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
afterEach(() => {
vi.clearAllMocks()
})
it('renders create form with empty fields', async () => {
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText('Add Proxy Host')).toBeInTheDocument()
})
expect(screen.getByPlaceholderText('example.com, www.example.com')).toHaveValue('')
})
it('renders edit form with pre-filled data', async () => {
const mockHost = {
uuid: '123',
domain_names: 'test.com',
forward_scheme: 'https',
forward_host: '192.168.1.100',
forward_port: 8443,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: true,
block_exploits: true,
websocket_support: false,
enabled: true,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
renderWithClient(
<ProxyHostForm host={mockHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText('Edit Proxy Host')).toBeInTheDocument()
})
expect(screen.getByDisplayValue('test.com')).toBeInTheDocument()
expect(screen.getByDisplayValue('192.168.1.100')).toBeInTheDocument()
})
it('loads remote servers for quick select', async () => {
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText(/Local Docker Registry/)).toBeInTheDocument()
})
})
it('calls onCancel when cancel button is clicked', async () => {
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText('Cancel')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Cancel'))
expect(mockOnCancel).toHaveBeenCalledOnce()
})
it('submits form with correct data', async () => {
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
const hostInput = screen.getByPlaceholderText('192.168.1.100')
const portInput = screen.getByDisplayValue('80')
fireEvent.change(domainInput, { target: { value: 'newsite.com' } })
fireEvent.change(hostInput, { target: { value: '10.0.0.1' } })
fireEvent.change(portInput, { target: { value: '9000' } })
fireEvent.click(screen.getByText('Create'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
domain_names: 'newsite.com',
forward_host: '10.0.0.1',
forward_port: 9000,
})
)
})
})
it('handles SSL and WebSocket checkboxes', async () => {
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByLabelText('Force SSL')).toBeInTheDocument()
})
const sslCheckbox = screen.getByLabelText('Force SSL')
const wsCheckbox = screen.getByLabelText('WebSocket Support')
expect(sslCheckbox).not.toBeChecked()
expect(wsCheckbox).not.toBeChecked()
fireEvent.click(sslCheckbox)
fireEvent.click(wsCheckbox)
expect(sslCheckbox).toBeChecked()
expect(wsCheckbox).toBeChecked()
})
it('populates fields when remote server is selected', async () => {
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText(/Local Docker Registry/)).toBeInTheDocument()
})
const select = screen.getByRole('combobox', { name: /quick select/i })
fireEvent.change(select, { target: { value: mockRemoteServers[0].uuid } })
expect(screen.getByDisplayValue(mockRemoteServers[0].host)).toBeInTheDocument()
expect(screen.getByDisplayValue(mockRemoteServers[0].port)).toBeInTheDocument()
})
it('displays error message on submission failure', async () => {
const mockErrorSubmit = vi.fn(() => Promise.reject(new Error('Submission failed')))
renderWithClient(
<ProxyHostForm onSubmit={mockErrorSubmit} onCancel={mockOnCancel} />
)
fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
target: { value: 'test.com' },
})
fireEvent.change(screen.getByPlaceholderText('192.168.1.100'), {
target: { value: 'localhost' },
})
fireEvent.change(screen.getByDisplayValue('80'), {
target: { value: '8080' },
})
fireEvent.click(screen.getByText('Create'))
await waitFor(() => {
expect(screen.getByText('Submission failed')).toBeInTheDocument()
})
})
it('handles advanced config input', async () => {
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const advancedInput = screen.getByLabelText(/Advanced Caddy Config/i)
fireEvent.change(advancedInput, { target: { value: 'header_up X-Test "True"' } })
expect(advancedInput).toHaveValue('header_up X-Test "True"')
})
})

View File

@@ -1,124 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import RemoteServerForm from '../RemoteServerForm'
// Mock the API
vi.mock('../../services/api', () => ({
remoteServersAPI: {
test: vi.fn(() => Promise.resolve({ reachable: true, address: 'localhost:8080' })),
},
}))
describe('RemoteServerForm', () => {
const mockOnSubmit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
afterEach(() => {
vi.clearAllMocks()
})
it('renders create form', () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Add Remote Server')).toBeInTheDocument()
expect(screen.getByPlaceholderText('My Production Server')).toHaveValue('')
})
it('renders edit form with pre-filled data', () => {
const mockServer = {
uuid: '123',
name: 'Test Server',
provider: 'docker',
host: 'localhost',
port: 5000,
username: 'admin',
enabled: true,
reachable: true,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
render(
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Edit Remote Server')).toBeInTheDocument()
expect(screen.getByDisplayValue('Test Server')).toBeInTheDocument()
expect(screen.getByDisplayValue('localhost')).toBeInTheDocument()
expect(screen.getByDisplayValue('5000')).toBeInTheDocument()
})
it('shows test connection button only in edit mode', () => {
const { rerender } = render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.queryByText('Test Connection')).not.toBeInTheDocument()
const mockServer = {
uuid: '123',
name: 'Test Server',
provider: 'docker',
host: 'localhost',
port: 5000,
enabled: true,
reachable: false,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
rerender(
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Test Connection')).toBeInTheDocument()
})
it('calls onCancel when cancel button is clicked', () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
fireEvent.click(screen.getByText('Cancel'))
expect(mockOnCancel).toHaveBeenCalledOnce()
})
it('submits form with correct data', async () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const nameInput = screen.getByPlaceholderText('My Production Server')
const hostInput = screen.getByPlaceholderText('192.168.1.100')
const portInput = screen.getByDisplayValue('22')
fireEvent.change(nameInput, { target: { value: 'New Server' } })
fireEvent.change(hostInput, { target: { value: '10.0.0.5' } })
fireEvent.change(portInput, { target: { value: '9090' } })
fireEvent.click(screen.getByText('Create'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New Server',
host: '10.0.0.5',
port: 9090,
})
)
})
})
it('handles provider selection', () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const providerSelect = screen.getByDisplayValue('Generic')
fireEvent.change(providerSelect, { target: { value: 'docker' } })
expect(providerSelect).toHaveValue('docker')
})
})

View File

@@ -1,55 +0,0 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'
import { clsx } from 'clsx'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
children: ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
isLoading = false,
className,
children,
disabled,
...props
}: ButtonProps) {
const baseStyles = 'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-700 text-white hover:bg-gray-600 focus:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
ghost: 'text-gray-400 hover:text-white hover:bg-gray-800 focus:ring-gray-500',
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
}
return (
<button
className={clsx(
baseStyles,
variants[variant],
sizes[size],
className
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{children}
</button>
)
}

View File

@@ -1,31 +0,0 @@
import { ReactNode } from 'react'
import { clsx } from 'clsx'
interface CardProps {
children: ReactNode
className?: string
title?: string
description?: string
footer?: ReactNode
}
export function Card({ children, className, title, description, footer }: CardProps) {
return (
<div className={clsx('bg-dark-card rounded-lg border border-gray-800 overflow-hidden', className)}>
{(title || description) && (
<div className="px-6 py-4 border-b border-gray-800">
{title && <h3 className="text-lg font-medium text-white">{title}</h3>}
{description && <p className="mt-1 text-sm text-gray-400">{description}</p>}
</div>
)}
<div className="p-6">
{children}
</div>
{footer && (
<div className="px-6 py-4 bg-gray-900/50 border-t border-gray-800">
{footer}
</div>
)}
</div>
)
}

View File

@@ -1,41 +0,0 @@
import { InputHTMLAttributes, forwardRef } from 'react'
import { clsx } from 'clsx'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, className, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{label}
</label>
)}
<input
ref={ref}
className={clsx(
'w-full bg-gray-900 border rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 transition-colors',
error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-700 focus:ring-blue-500 focus:border-blue-500',
className
)}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-400">{error}</p>
)}
{helperText && !error && (
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'