new changes
This commit is contained in:
@@ -546,6 +546,19 @@ export async function getDashboardStats() {
|
||||
}>('/dashboard/stats')
|
||||
}
|
||||
|
||||
export async function applySiteSsl(data: {
|
||||
site_id: number
|
||||
domains: string[]
|
||||
method: 'file' | 'dns_cloudflare'
|
||||
email: string
|
||||
api_token?: string
|
||||
}) {
|
||||
return apiRequest<{ status: boolean; msg: string; output?: string }>('/ssl/site-apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getFirewallBackendStatus() {
|
||||
return apiRequest<{
|
||||
ufw: { detected: boolean; active: boolean | null; summary_line: string }
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
siteGitClone,
|
||||
siteGitPull,
|
||||
listServices,
|
||||
applySiteSsl,
|
||||
type SiteListItem,
|
||||
} from '../api/client'
|
||||
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
|
||||
@@ -29,6 +30,25 @@ function formatPhpVersion(code: string): string {
|
||||
return m[code] || code
|
||||
}
|
||||
|
||||
/** Domain column may be `host` or `host:port` (match API list). */
|
||||
function hostFromDomainEntry(entry: string): string {
|
||||
return (entry || '').split(':')[0].trim()
|
||||
}
|
||||
|
||||
function uniqueHostsFromSiteDomains(domains: string[] | undefined): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const entry of domains || []) {
|
||||
const h = hostFromDomainEntry(entry)
|
||||
const k = h.toLowerCase()
|
||||
if (k && !seen.has(k)) {
|
||||
seen.add(k)
|
||||
out.push(h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function SitePage() {
|
||||
const [sites, setSites] = useState<SiteListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -73,6 +93,63 @@ export function SitePage() {
|
||||
const [nginxStatus, setNginxStatus] = useState<string | null>(null)
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
const [rowBackupId, setRowBackupId] = useState<number | null>(null)
|
||||
const [sslSite, setSslSite] = useState<SiteListItem | null>(null)
|
||||
const [sslTab, setSslTab] = useState<'current' | 'letsencrypt'>('letsencrypt')
|
||||
const [sslMethod, setSslMethod] = useState<'file' | 'dns_cloudflare'>('file')
|
||||
const [sslEmail, setSslEmail] = useState('')
|
||||
const [sslCfToken, setSslCfToken] = useState('')
|
||||
const [sslSelectedHosts, setSslSelectedHosts] = useState<Set<string>>(() => new Set())
|
||||
const [sslBusy, setSslBusy] = useState(false)
|
||||
const [sslError, setSslError] = useState('')
|
||||
const [sslSuccess, setSslSuccess] = useState('')
|
||||
|
||||
const sslHostOptions = useMemo(() => (sslSite ? uniqueHostsFromSiteDomains(sslSite.domains) : []), [sslSite])
|
||||
|
||||
const openSslModal = (s: SiteListItem) => {
|
||||
setSslSite(s)
|
||||
setSslTab('letsencrypt')
|
||||
setSslMethod('file')
|
||||
setSslEmail('')
|
||||
setSslCfToken('')
|
||||
setSslSelectedHosts(new Set(uniqueHostsFromSiteDomains(s.domains)))
|
||||
setSslError('')
|
||||
setSslSuccess('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
const toggleSslHost = (host: string, checked: boolean) => {
|
||||
setSslSelectedHosts((prev) => {
|
||||
const n = new Set(prev)
|
||||
if (checked) n.add(host)
|
||||
else n.delete(host)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
const sslSelectAll = (on: boolean) => {
|
||||
setSslSelectedHosts(on ? new Set(sslHostOptions) : new Set())
|
||||
}
|
||||
|
||||
const handleSiteSslApply = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!sslSite || sslSelectedHosts.size === 0 || !sslEmail.trim()) return
|
||||
setSslBusy(true)
|
||||
setSslError('')
|
||||
setSslSuccess('')
|
||||
applySiteSsl({
|
||||
site_id: sslSite.id,
|
||||
domains: [...sslSelectedHosts],
|
||||
method: sslMethod,
|
||||
email: sslEmail.trim(),
|
||||
api_token: sslMethod === 'dns_cloudflare' ? sslCfToken.trim() : undefined,
|
||||
})
|
||||
.then((r) => {
|
||||
setSslSuccess(r.msg || 'Certificate request completed.')
|
||||
loadSites()
|
||||
})
|
||||
.catch((err) => setSslError(err.message))
|
||||
.finally(() => setSslBusy(false))
|
||||
}
|
||||
|
||||
const loadSites = () => {
|
||||
setLoading(true)
|
||||
@@ -671,9 +748,13 @@ export function SitePage() {
|
||||
<i className="ti ti-file-text me-2" />
|
||||
Logs
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item as={Link} to="/ssl_domain">
|
||||
<Dropdown.Item onClick={() => openSslModal(s)}>
|
||||
<i className="ti ti-shield-lock me-2" />
|
||||
SSL / Domains
|
||||
SSL (this site)
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item as={Link} to="/ssl_domain">
|
||||
<i className="ti ti-world-www me-2" />
|
||||
Domains & diagnostics
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item
|
||||
@@ -1015,6 +1096,225 @@ export function SitePage() {
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
show={sslSite != null}
|
||||
onHide={() => {
|
||||
setSslSite(null)
|
||||
setSslError('')
|
||||
setSslSuccess('')
|
||||
}}
|
||||
size="lg"
|
||||
centered
|
||||
scrollable
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title className="d-flex align-items-center gap-2">
|
||||
<i className="ti ti-shield-lock" aria-hidden />
|
||||
SSL — {sslSite?.name}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{sslSite ? (
|
||||
<>
|
||||
<ul className="nav nav-tabs mb-3">
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-link ${sslTab === 'current' ? 'active' : ''}`}
|
||||
onClick={() => setSslTab('current')}
|
||||
>
|
||||
Current certificate
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-link ${sslTab === 'letsencrypt' ? 'active' : ''}`}
|
||||
onClick={() => setSslTab('letsencrypt')}
|
||||
>
|
||||
Let's Encrypt
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{sslTab === 'current' ? (
|
||||
<div>
|
||||
{(sslSite.ssl?.status ?? 'none') === 'none' ? (
|
||||
<div className="alert alert-warning">
|
||||
This site has no matching Let's Encrypt certificate detected on the server. Use the Let's
|
||||
Encrypt tab to issue one, or open Domains for global checks.
|
||||
</div>
|
||||
) : sslSite.ssl?.status === 'expired' ? (
|
||||
<div className="alert alert-danger">
|
||||
Certificate appears <strong>expired</strong> for monitored hostnames. Renew from the Let's
|
||||
Encrypt tab.
|
||||
</div>
|
||||
) : sslSite.ssl?.status === 'expiring' ? (
|
||||
<div className="alert alert-warning">
|
||||
Certificate expires in <strong>{sslSite.ssl?.days_left ?? 0}</strong> days — consider renewing soon.
|
||||
</div>
|
||||
) : (
|
||||
<div className="alert alert-success mb-0">
|
||||
Certificate looks active (~{sslSite.ssl?.days_left ?? '—'} days left for matched hostname
|
||||
{sslSite.ssl?.cert_name ? (
|
||||
<>
|
||||
: <code>{sslSite.ssl.cert_name}</code>
|
||||
</>
|
||||
) : null}
|
||||
).
|
||||
</div>
|
||||
)}
|
||||
<p className="small text-muted mt-3 mb-0">
|
||||
<Link to="/ssl_domain">Open Domains & SSL</Link> for nginx include wizard, DNS-01 helpers, and all
|
||||
certificates on the server.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSiteSslApply}>
|
||||
{(sslSite.ssl?.status ?? 'none') !== 'active' ? (
|
||||
<div className="alert alert-danger d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<span>
|
||||
<strong>Tip:</strong> HTTPS is not fully active for this site's domains — visitors may see
|
||||
browser warnings until a certificate is deployed.
|
||||
</span>
|
||||
<span className="badge bg-success text-wrap">Use the form below to apply</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="alert alert-secondary small mb-3">
|
||||
A certificate is already detected as active; you can still re-issue to add names or renew early.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="fw-semibold mb-2">Validation</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name="sslVal"
|
||||
id="ssl-file"
|
||||
checked={sslMethod === 'file'}
|
||||
onChange={() => setSslMethod('file')}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="ssl-file">
|
||||
File verification (HTTP-01, webroot on this site)
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name="sslVal"
|
||||
id="ssl-dns"
|
||||
checked={sslMethod === 'dns_cloudflare'}
|
||||
onChange={() => setSslMethod('dns_cloudflare')}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="ssl-dns">
|
||||
DNS verification — Cloudflare (works behind CDN / orange-cloud)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
|
||||
<label className="fw-semibold mb-0">Domains on this site</label>
|
||||
{sslHostOptions.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-sm py-0"
|
||||
onClick={() => sslSelectAll(sslSelectedHosts.size < sslHostOptions.length)}
|
||||
>
|
||||
{sslSelectedHosts.size < sslHostOptions.length ? 'Select all' : 'Clear all'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{sslHostOptions.length === 0 ? (
|
||||
<p className="text-danger small mb-0">No domains on this site — add domains in site settings first.</p>
|
||||
) : (
|
||||
<div className="border rounded p-3 bg-body-secondary bg-opacity-25">
|
||||
{sslHostOptions.map((h) => (
|
||||
<div className="form-check" key={h}>
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id={`ssl-host-${h}`}
|
||||
checked={sslSelectedHosts.has(h)}
|
||||
onChange={(ev) => toggleSslHost(h, ev.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor={`ssl-host-${h}`}>
|
||||
{h}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Let's Encrypt email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
value={sslEmail}
|
||||
onChange={(e) => setSslEmail(e.target.value)}
|
||||
placeholder="admin@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sslMethod === 'dns_cloudflare' ? (
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Cloudflare API token (DNS:Edit)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={sslCfToken}
|
||||
onChange={(e) => setSslCfToken(e.target.value)}
|
||||
placeholder="API token"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ul className="small text-muted mb-3">
|
||||
<li>
|
||||
Confirm DNS A/AAAA records for every selected name point to this server before using file
|
||||
verification.
|
||||
</li>
|
||||
<li>If you use a CDN or redirect, file validation may fail — use Cloudflare DNS verification.</li>
|
||||
<li>
|
||||
Requires <code>certbot</code> on the server; DNS method also needs <code>certbot-dns-cloudflare</code>.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{sslError ? <AdminAlert variant="danger">{sslError}</AdminAlert> : null}
|
||||
{sslSuccess ? <AdminAlert variant="success">{sslSuccess}</AdminAlert> : null}
|
||||
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-light"
|
||||
onClick={() => {
|
||||
setSslSite(null)
|
||||
setSslError('')
|
||||
setSslSuccess('')
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" className="btn btn-success" disabled={sslBusy || sslHostOptions.length === 0}>
|
||||
{sslBusy ? 'Running certbot…' : 'Apply certificate'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
|
||||
<Modal show={backupSiteId != null} onHide={() => setBackupSiteId(null)} size="lg" centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Site Backup</Modal.Title>
|
||||
|
||||
Reference in New Issue
Block a user