Files
MikrotikManager/components/certificates/certificate-acme-settings.tsx
T

122 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}