diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 17970a7b..3a370c4e 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -173,6 +173,10 @@ export default function IpsecPage() { const [loading, setLoading] = useState(false) const [busy, setBusy] = useState(false) const [certBusy, setCertBusy] = useState(false) + const [certError, setCertError] = useState(null) + const certEpoch = useRef(0) + const certPending = useRef(false) + useEffect(() => () => { certEpoch.current += 1 }, []) const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [workspaceTab, setWorkspaceTab] = useState("clients") @@ -393,6 +397,10 @@ export default function IpsecPage() { setEditing(null) await loadLive() if (res.bundle) { + certEpoch.current += 1 + certPending.current = false + setCertBusy(false) + setCertError(null) setCertBundle(res.bundle) setCertClient(res.client ?? null) setCertByName(null) @@ -421,63 +429,51 @@ export default function IpsecPage() { } } + const loadCert = async (request: () => Promise) => { + if (certPending.current) return + certPending.current = true + const epoch = ++certEpoch.current + setCertBusy(true) + setCertError(null) + try { + const bundle = await request() + if (epoch === certEpoch.current) setCertBundle(bundle) + } catch (error) { + if (epoch === certEpoch.current) setCertError(error instanceof Error ? error.message : "Ошибка экспорта сертификата") + } finally { + if (epoch === certEpoch.current) { certPending.current = false; setCertBusy(false) } + } + } + const openCert = async (client: IpsecClientDto) => { const byName = client.kind === "cert" && Boolean(client.certName) + certEpoch.current += 1 + certPending.current = false setCertClient(byName ? null : client) setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null) setCertBundle(null) setCertOpen(true) - setCertBusy(true) - try { - const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}` - const bundle = byName - ? await exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase) - : await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase) - setCertBundle(bundle) - } catch (e) { - toast.error("Ошибка экспорта сертификата", { - description: e instanceof Error ? e.message : String(e), - }) - setCertOpen(false) - } finally { - setCertBusy(false) - } + const passphrase = crypto.randomUUID() + await loadCert(() => byName + ? exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase) + : exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)) } - /** Экспорт .p12 существующего клиентского серта по имени (client1/anakondra и т.п.). */ const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => { + certEpoch.current += 1 + certPending.current = false setCertByName({ serverId, name: cert.name }) setCertClient(null) setCertBundle(null) setCertOpen(true) - setCertBusy(true) - try { - const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}` - const bundle = await exportIpsecCertByName(backendUrl, serverId, cert.name, passphrase) - setCertBundle(bundle) - } catch (e) { - toast.error("Ошибка экспорта сертификата", { - description: e instanceof Error ? e.message : String(e), - }) - setCertOpen(false) - } finally { - setCertBusy(false) - } + await loadCert(() => exportIpsecCertByName(backendUrl, serverId, cert.name, crypto.randomUUID())) } const reexportCert = async (passphrase: string) => { if (!certClient && !certByName) return - setCertBusy(true) - try { - const bundle = certByName - ? await exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase) - : await exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase) - setCertBundle(bundle) - } catch (e) { - toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) }) - } finally { - setCertBusy(false) - } + await loadCert(() => certByName + ? exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase) + : exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)) } const confirmDelete = async () => { @@ -850,7 +846,11 @@ export default function IpsecPage() { { setCertOpen(v); if (!v) setCertByName(null) }} + onOpenChange={(v) => { + if (!v) { certEpoch.current += 1; certPending.current = false; setCertBusy(false); setCertBundle(null); setCertClient(null); setCertByName(null) } + setCertOpen(v) + }} + error={certError} bundle={certBundle} busy={certBusy} onReexport={reexportCert} diff --git a/components/ipsec/ipsec-cert-sheet.tsx b/components/ipsec/ipsec-cert-sheet.tsx index 5db61ada..ffd18a8f 100644 --- a/components/ipsec/ipsec-cert-sheet.tsx +++ b/components/ipsec/ipsec-cert-sheet.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useMemo, useState } from "react" + import { IPSEC_MIN_PASSPHRASE, type IpsecCertBundle } from "@mmapp/contracts/ipsec" import { FormField, SectionTitle } from "@/components/form-kit" import { Button } from "@/components/ui/button" @@ -46,27 +46,19 @@ function IpsecCertSheet({ onOpenChange, bundle, busy, + error, onReexport, }: { open: boolean onOpenChange: (v: boolean) => void bundle: IpsecCertBundle | null busy?: boolean + error?: string | null /** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */ onReexport?: (passphrase: string) => void | Promise }) { - const [passphrase, setPassphrase] = useState("") - - useEffect(() => { - if (!open) return - const initial = bundle?.passphrase ?? "" - queueMicrotask(() => setPassphrase(initial)) - }, [open, bundle]) - - const canDownload = useMemo( - () => Boolean(bundle && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE), - [bundle, passphrase], - ) + const passphrase = bundle?.passphrase ?? "" + const canDownload = Boolean(bundle && !busy && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE) return ( @@ -79,10 +71,10 @@ function IpsecCertSheet({
+ {busy &&

Загрузка сертификата…

} + {error &&

{error}

} {!bundle ? ( -

- Бандл сертификата пуст — перезапустите экспорт с новой парольной фразой. -

+ !busy && onReexport ? : null ) : ( <>
@@ -100,17 +92,18 @@ function IpsecCertSheet({ setPassphrase(e.target.value)} + readOnly + aria-label="Пароль архива .p12" />