216 lines
8.2 KiB
TypeScript
216 lines
8.2 KiB
TypeScript
|
|
import { useEffect, useState } from 'react'
|
||
|
|
import { apiRequest } from '../api/client'
|
||
|
|
import { Shield, Loader2 } from 'lucide-react'
|
||
|
|
|
||
|
|
interface Domain {
|
||
|
|
id: number
|
||
|
|
name: string
|
||
|
|
port: string
|
||
|
|
site_id: number
|
||
|
|
site_name: string
|
||
|
|
site_path: string
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Certificate {
|
||
|
|
name: string
|
||
|
|
path: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export function DomainsPage() {
|
||
|
|
const [domains, setDomains] = useState<Domain[]>([])
|
||
|
|
const [certificates, setCertificates] = useState<Certificate[]>([])
|
||
|
|
const [loading, setLoading] = useState(true)
|
||
|
|
const [error, setError] = useState('')
|
||
|
|
const [requesting, setRequesting] = useState<string | null>(null)
|
||
|
|
const [requestDomain, setRequestDomain] = useState<Domain | null>(null)
|
||
|
|
const [requestEmail, setRequestEmail] = useState('')
|
||
|
|
|
||
|
|
const load = () => {
|
||
|
|
setLoading(true)
|
||
|
|
Promise.all([
|
||
|
|
apiRequest<Domain[]>('/ssl/domains'),
|
||
|
|
apiRequest<{ certificates: Certificate[] }>('/ssl/certificates'),
|
||
|
|
])
|
||
|
|
.then(([d, c]) => {
|
||
|
|
setDomains(d)
|
||
|
|
setCertificates(c.certificates || [])
|
||
|
|
})
|
||
|
|
.catch((err) => setError(err.message))
|
||
|
|
.finally(() => setLoading(false))
|
||
|
|
}
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
load()
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
const handleRequestCert = (e: React.FormEvent) => {
|
||
|
|
e.preventDefault()
|
||
|
|
if (!requestDomain) return
|
||
|
|
setRequesting(requestDomain.name)
|
||
|
|
apiRequest<{ status: boolean }>('/ssl/request', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({
|
||
|
|
domain: requestDomain.name,
|
||
|
|
webroot: requestDomain.site_path,
|
||
|
|
email: requestEmail,
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
.then(() => {
|
||
|
|
setRequestDomain(null)
|
||
|
|
load()
|
||
|
|
})
|
||
|
|
.catch((err) => setError(err.message))
|
||
|
|
.finally(() => setRequesting(null))
|
||
|
|
}
|
||
|
|
|
||
|
|
const hasCert = (domain: string) =>
|
||
|
|
certificates.some((c) => c.name === domain || c.name.startsWith(domain + ' '))
|
||
|
|
|
||
|
|
if (loading) return <div className="text-gray-500">Loading...</div>
|
||
|
|
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Domains & SSL</h1>
|
||
|
|
|
||
|
|
<div className="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-800 dark:text-amber-200">
|
||
|
|
<p>Request Let's Encrypt certificates for your site domains. Requires certbot and nginx configured for the domain.</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||
|
|
<h2 className="px-4 py-2 border-b dark:border-gray-700 font-medium text-gray-800 dark:text-white">
|
||
|
|
Domains (from sites)
|
||
|
|
</h2>
|
||
|
|
<div className="divide-y divide-gray-200 dark:divide-gray-700 max-h-80 overflow-y-auto">
|
||
|
|
{domains.length === 0 ? (
|
||
|
|
<div className="px-4 py-8 text-center text-gray-500">No domains. Add a site first.</div>
|
||
|
|
) : (
|
||
|
|
domains.map((d) => (
|
||
|
|
<div
|
||
|
|
key={d.id}
|
||
|
|
className="flex items-center justify-between gap-2 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||
|
|
>
|
||
|
|
<div>
|
||
|
|
<span className="font-mono text-gray-900 dark:text-white">{d.name}</span>
|
||
|
|
{d.port !== '80' && (
|
||
|
|
<span className="ml-2 text-gray-500 text-sm">:{d.port}</span>
|
||
|
|
)}
|
||
|
|
<span className="ml-2 text-gray-500 text-sm">({d.site_name})</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
{hasCert(d.name) ? (
|
||
|
|
<span className="text-green-600 dark:text-green-400 text-sm flex items-center gap-1">
|
||
|
|
<Shield className="w-4 h-4" /> Cert
|
||
|
|
</span>
|
||
|
|
) : (
|
||
|
|
<button
|
||
|
|
onClick={() => {
|
||
|
|
setRequestDomain(d)
|
||
|
|
setRequestEmail('')
|
||
|
|
}}
|
||
|
|
disabled={!!requesting}
|
||
|
|
className="text-sm px-2 py-1 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/50 disabled:opacity-50"
|
||
|
|
>
|
||
|
|
{requesting === d.name ? (
|
||
|
|
<Loader2 className="w-4 h-4 animate-spin inline" />
|
||
|
|
) : (
|
||
|
|
'Request SSL'
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||
|
|
<h2 className="px-4 py-2 border-b dark:border-gray-700 font-medium text-gray-800 dark:text-white">
|
||
|
|
Certificates
|
||
|
|
</h2>
|
||
|
|
<div className="divide-y divide-gray-200 dark:divide-gray-700 max-h-80 overflow-y-auto">
|
||
|
|
{certificates.length === 0 ? (
|
||
|
|
<div className="px-4 py-8 text-center text-gray-500">No certificates yet</div>
|
||
|
|
) : (
|
||
|
|
certificates.map((c) => (
|
||
|
|
<div
|
||
|
|
key={c.name}
|
||
|
|
className="flex items-center gap-2 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||
|
|
>
|
||
|
|
<Shield className="w-4 h-4 text-green-600 flex-shrink-0" />
|
||
|
|
<span className="font-mono text-gray-900 dark:text-white">{c.name}</span>
|
||
|
|
</div>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{requestDomain && (
|
||
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||
|
|
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">
|
||
|
|
Request SSL for {requestDomain.name}
|
||
|
|
</h2>
|
||
|
|
<form onSubmit={handleRequestCert} className="space-y-4">
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
|
|
Domain
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={requestDomain.name}
|
||
|
|
readOnly
|
||
|
|
className="w-full px-4 py-2 border rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
|
|
Webroot (site path)
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={requestDomain.site_path}
|
||
|
|
readOnly
|
||
|
|
className="w-full px-4 py-2 border rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
|
|
Email (for Let's Encrypt)
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="email"
|
||
|
|
value={requestEmail}
|
||
|
|
onChange={(e) => setRequestEmail(e.target.value)}
|
||
|
|
placeholder="admin@example.com"
|
||
|
|
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex gap-2 justify-end pt-2">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setRequestDomain(null)}
|
||
|
|
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="submit"
|
||
|
|
disabled={!!requesting}
|
||
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
|
||
|
|
>
|
||
|
|
{requesting ? 'Requesting...' : 'Request'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|