fix(ui): сохранять черновики и результат настроек ACME

This commit is contained in:
Denozordec committed 2026-10-01 14:03:30 +07:00
1 parent 1c407b77de
commit 594e60a1f4
7 files changed
+223 -161

No files matched your search

@@ -0,0 +1,121 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { putAcmeCloudflareSettingsSchema, type AcmeCloudflareSettingsDto } from "@mmapp/contracts/certificates"
import { getAcmeSettings, putAcmeSettings, testAcmeSettings } from "@/shared/api/certificates"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { toast } from "sonner"
/** Reuses Settings 16 composition: https://reui.io/preview/base/settings-16?ref=mcp */
export function CertificateAcmeSettings({ backendUrl, liveReady, canRead, canWrite }: { backendUrl: string; liveReady: boolean; canRead: boolean; canWrite: boolean }) {
const [confirmed, setConfirmed] = useState<AcmeCloudflareSettingsDto | null>(null)
const [directory, setDirectory] = useState("")
const [zone, setZone] = useState("")
const [token, setToken] = useState("")
const [loading, setLoading] = useState(liveReady && canRead)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [testResult, setTestResult] = useState<string | null>(null)
const dirty = useRef(false)
const lock = useRef(false)
const alive = useRef(true)
const controller = useRef<AbortController | null>(null)
const load = useCallback(async () => {
if (!liveReady || !canRead || lock.current || !alive.current) return
controller.current?.abort()
const abort = new AbortController()
controller.current = abort
setLoading(true)
try {
const saved = await getAcmeSettings(backendUrl, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
setConfirmed(saved)
if (!dirty.current) { setDirectory(saved.directoryUrl); setZone(saved.defaultZoneId ?? "") }
setError(null)
} catch (reason) {
if (alive.current && !abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Не удалось прочитать ACME настройки")
} finally {
if (alive.current && !abort.signal.aborted) setLoading(false)
}
}, [backendUrl, liveReady, canRead])
useEffect(() => {
alive.current = true
const cancel = () => { alive.current = false; controller.current?.abort() }
return cancel
}, [])
useEffect(() => { queueMicrotask(() => { if (alive.current) void load() }) }, [load])
async function operate(action: "save" | "test") {
if (!liveReady || !canRead || !canWrite || !confirmed || loading || lock.current || !alive.current) return
const payload = { directoryUrl: directory.trim(), defaultZoneId: zone.trim() || null, cloudflareApiToken: token.trim() || undefined }
if (action === "save" && !putAcmeCloudflareSettingsSchema.safeParse(payload).success) { setError("Укажите корректный URL ACME directory."); return }
if (action === "test" && !token.trim() && !confirmed.tokenConfigured) { setError("Введите токен для проверки. Сохранённого токена нет."); return }
lock.current = true
setBusy(true)
setTestResult(null)
const abort = new AbortController()
controller.current = abort
try {
if (action === "save") {
const saved = await putAcmeSettings(backendUrl, payload, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
setConfirmed(saved)
if (payload.cloudflareApiToken && !saved.tokenConfigured) throw new Error("Сохранение токена не подтверждено")
setDirectory(saved.directoryUrl); setZone(saved.defaultZoneId ?? ""); setToken("")
dirty.current = false
setError(null)
toast.success("Настройки ACME сохранены")
} else {
const result = await testAcmeSettings(backendUrl, { cloudflareApiToken: token.trim() || undefined }, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
if (!result.ok) throw new Error(result.message ?? "Проверка Cloudflare не прошла")
setError(null)
setTestResult(result.message ?? "Cloudflare API доступен")
}
} catch (reason) {
if (alive.current && !abort.signal.aborted) setError((reason instanceof Error ? reason.message : "Операция не выполнена") + (action === "save" ? ". Результат сохранения не подтверждён; черновик сохранён в форме." : ". Проверка не сохраняет настройки."))
} finally {
lock.current = false
if (alive.current && !abort.signal.aborted) setBusy(false)
}
}
const disabled = !liveReady || !canRead || !canWrite || !confirmed || loading || busy
function edit(set: (value: string) => void, value: string) { dirty.current = true; setTestResult(null); set(value) }
return (
<OpsPanel title="ACME · Cloudflare DNS-01" description="Публичные Let's Encrypt выпускаются на backend и импортируются на выбранный RouterOS 7.22+." contentClassName="px-5 py-4 flex flex-col gap-3">
{error && <p role="alert" aria-label="Настройки ACME">{error}</p>}
{loading && <p role="status">Загрузка настроек ACME…</p>}
{!confirmed && <p role="status">Настройки ACME пока неизвестны. Сохранение и проверка недоступны до успешного чтения.</p>}
{!liveReady && <p role="status">Нет подключения к API.</p>}
{!canRead && <p role="status">Нет права mm:certificates:read.</p>}
{!canWrite && <p role="status">Настройки ACME доступны только для чтения.</p>}
<p className="text-xs text-muted-foreground">{confirmed ? confirmed.tokenConfigured ? "Токен сохранён на backend." : "Сохранённого токена нет." : "Состояние токена неизвестно."}</p>
{testResult && <p role="status">{testResult}. Настройки не изменены.</p>}
{liveReady && canRead && <Button variant="outline" size="sm" disabled={busy || loading} onClick={() => void load()}>Перечитать настройки ACME</Button>}
<form className="grid gap-3 md:grid-cols-2" autoComplete="off" onSubmit={event => event.preventDefault()}>
<div className="flex flex-col gap-1.5">
<label htmlFor="certificate-acme-directory" className="text-sm font-medium">ACME directory URL</label>
<Input id="certificate-acme-directory" name="acme-directory-url" autoComplete="off" disabled={disabled} value={directory} onChange={event => edit(setDirectory, event.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="certificate-acme-zone" className="text-sm font-medium">Cloudflare zone id (опционально)</label>
<Input id="certificate-acme-zone" name="cloudflare-zone-id" autoComplete="off" disabled={disabled} value={zone} onChange={event => edit(setZone, event.target.value)} placeholder="Авто по домену" />
</div>
<div className="flex flex-col gap-1.5 md:col-span-2">
<label htmlFor="certificate-acme-token" className="text-sm font-medium">Cloudflare API token</label>
<Input id="certificate-acme-token" name="cloudflare-api-token" type="password" autoComplete="new-password" disabled={disabled} value={token} onChange={event => edit(setToken, event.target.value)} placeholder={confirmed?.tokenConfigured ? "Введите новый для замены; пустое поле сохранит прежний" : "API token с правом DNS"} />
<p className="text-xs text-muted-foreground">Проверка использует введённый токен или сохранённый на backend. Сохранение выполняется отдельно.</p>
</div>
</form>
{canWrite && <div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" disabled={disabled} onClick={() => void operate("test")}>Проверить Cloudflare</Button>
<Button size="sm" disabled={disabled} onClick={() => void operate("save")}>Сохранить настройки</Button>
</div>}
</OpsPanel>
)
}