"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(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(null) const [testResult, setTestResult] = useState(null) const dirty = useRef(false) const lock = useRef(false) const alive = useRef(true) const controller = useRef(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 ( {error &&

{error}

} {loading &&

Загрузка настроек ACME…

} {!confirmed &&

Настройки ACME пока неизвестны. Сохранение и проверка недоступны до успешного чтения.

} {!liveReady &&

Нет подключения к API.

} {!canRead &&

Нет права mm:certificates:read.

} {!canWrite &&

Настройки ACME доступны только для чтения.

}

{confirmed ? confirmed.tokenConfigured ? "Токен сохранён на backend." : "Сохранённого токена нет." : "Состояние токена неизвестно."}

{testResult &&

{testResult}. Настройки не изменены.

} {liveReady && canRead && }
event.preventDefault()}>
edit(setDirectory, event.target.value)} />
edit(setZone, event.target.value)} placeholder="Авто по домену" />
edit(setToken, event.target.value)} placeholder={confirmed?.tokenConfigured ? "Введите новый для замены; пустое поле сохранит прежний" : "API token с правом DNS"} />

Проверка использует введённый токен или сохранённый на backend. Сохранение выполняется отдельно.

{canWrite &&
}
) }