From 611f31875e9efc91e42020626bbab3e91061599d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 29 Sep 2026 19:28:26 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20IPsec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/ipsec/page.tsx | 63 ++++++++++++---------------------- docs/ux-ui-plan.md | 4 +-- docs/ux-ui-progress.md | 11 +++++- docs/ux-ui-stage-9.md | 6 +++- shared/api/ipsec.ts | 16 ++++++--- tests/e2e/ipsec-delete.spec.ts | 62 +++++++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 49 deletions(-) create mode 100644 tests/e2e/ipsec-delete.spec.ts diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 3a370c4e..22553602 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -191,6 +191,9 @@ export default function IpsecPage() { const [peerOpen, setPeerOpen] = useState(false) const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null) const [pendingDelete, setPendingDelete] = useState(null) + const deleteLock = useRef(false) + const [deleteError, setDeleteError] = useState(null) + const [forceAllowed, setForceAllowed] = useState(false) const [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) const [historyLoading, setHistoryLoading] = useState(false) @@ -476,57 +479,32 @@ export default function IpsecPage() { : exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)) } - const confirmDelete = async () => { - if (!pendingDelete) return + const confirmDelete = async (force = false) => { + if (!pendingDelete || deleteLock.current || (force && !forceAllowed)) return const target = pendingDelete + deleteLock.current = true setBusy(true) + setDeleteError(null) try { if (target.kind === "user") { await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId) - toast.success(`Клиент «${target.client.name}» удалён`) } else if (target.kind === "server") { await deleteIpsecServer(backendUrl, target.summary.serverId) - toast.success(`IKEv2-сервер на ${target.summary.serverName} удалён`) } else if (target.kind === "peer") { - await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId) - toast.success(`Peer «${target.peer.name}» удалён`) + await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force }) } else { - await deleteIpsecCert(backendUrl, target.serverId, target.cert.name) - toast.success(`Сертификат «${target.cert.name}» удалён`) + await deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force }) } - await loadLive() - } catch (e) { - const forceDelete = async (fn: () => Promise) => { - try { - await fn() - toast.success("Удалено") - await loadLive() - } catch (err) { - toast.error("Не удалось удалить", { description: err instanceof Error ? err.message : String(err) }) - } - } - if (e instanceof ApiClientError && e.status === 409 && target.kind === "peer") { - toast.error("На peer ссылаются identity", { - description: e.message, - action: { - label: "Удалить принудительно", - onClick: () => { void forceDelete(() => deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force: true })) }, - }, - }) - } else if (e instanceof ApiClientError && e.status === 409 && target.kind === "cert") { - toast.error("Сертификат используется", { - description: e.message, - action: { - label: "Удалить принудительно", - onClick: () => { void forceDelete(() => deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force: true })) }, - }, - }) - } else { - toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) }) - } - } finally { - setBusy(false) + toast.success("Удалено") setPendingDelete(null) + setForceAllowed(false) + await loadLive() + } catch (error) { + setDeleteError(error instanceof Error ? error.message : "Не удалось удалить") + setForceAllowed(error instanceof ApiClientError && error.status === 409 && (target.kind === "peer" || target.kind === "cert")) + } finally { + deleteLock.current = false + setBusy(false) } } @@ -856,7 +834,7 @@ export default function IpsecPage() { onReexport={reexportCert} /> - { if (!v) setPendingDelete(null) }}> + { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null); setForceAllowed(false) } }}> @@ -864,9 +842,12 @@ export default function IpsecPage() { {deleteDialogTitle} {deleteDialogDescription} + {busy &&

Ожидание результата удаления…

} + {deleteError &&

{deleteError}

}
Отмена + {forceAllowed && } 0 ? `?${q.toString()}` : "" - return requestJson<{ ok: boolean }>( + const result = await requestJson<{ ok: boolean }>( baseUrl, `/api/ipsec/server/${encodeURIComponent(serverId)}${suffix}`, { method: "DELETE" }, ) + if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление") + return result } export async function createIpsecUser( @@ -90,11 +92,13 @@ export async function deleteIpsecUser( const q = new URLSearchParams() if (opts?.removeCertificate === false) q.set("removeCertificate", "false") const suffix = q.size > 0 ? `?${q.toString()}` : "" - return requestJson<{ ok: boolean }>( + const result = await requestJson<{ ok: boolean }>( baseUrl, `/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}${suffix}`, { method: "DELETE" }, ) + if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление") + return result } function parseExportBundle(response: unknown): IpsecCertBundle { @@ -169,11 +173,13 @@ export async function deleteIpsecPeer( const q = new URLSearchParams() if (opts?.force) q.set("force", "true") const suffix = q.size > 0 ? `?${q.toString()}` : "" - return requestJson<{ ok: boolean }>( + const result = await requestJson<{ ok: boolean }>( baseUrl, `/api/ipsec/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(peerId)}${suffix}`, { method: "DELETE" }, ) + if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление") + return result } export async function deleteIpsecCert( @@ -185,9 +191,11 @@ export async function deleteIpsecCert( const q = new URLSearchParams() if (opts?.force) q.set("force", "true") const suffix = q.size > 0 ? `?${q.toString()}` : "" - return requestJson<{ ok: boolean }>( + const result = await requestJson<{ ok: boolean }>( baseUrl, `/api/ipsec/certs/${encodeURIComponent(serverId)}${suffix}`, { method: "DELETE", body: JSON.stringify({ name }) }, ) + if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление") + return result } diff --git a/tests/e2e/ipsec-delete.spec.ts b/tests/e2e/ipsec-delete.spec.ts new file mode 100644 index 00000000..ef9252ea --- /dev/null +++ b/tests/e2e/ipsec-delete.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "@playwright/test" + +for (const kind of ["user", "server", "peer", "cert"] as const) { + test(`IPsec: удаление ${kind} сохраняет подтверждение и защищает повтор`, async ({ page }) => { + const peer = { id: "peer", rosId: "*P/1", serverId: "1", serverName: "Роутер", name: "peer-test", managed: true, disabled: false } + const cert = { name: "cert-test", managed: true, role: "client" } + const client = { id: "client", rosId: "*C/1", serverId: "1", serverName: "Роутер", name: "alice", authMethod: "certificate", online: false, disabled: false, managed: true } + let attempts = 0 + let release: (() => void) | undefined + const gate = new Promise(resolve => { release = resolve }) + const canForce = kind === "peer" || kind === "cert" + await page.route("**/health", route => route.fulfill({ json: { ok: true } })) + await page.route("**/api/**", async route => { + const url = new URL(route.request().url()) + const path = url.pathname + if (route.request().method() === "DELETE") { + attempts++ + expect(decodeURIComponent(path)).toBe(kind === "user" ? "/api/ipsec/users/1/*C/1" : kind === "server" ? "/api/ipsec/server/1" : kind === "peer" ? "/api/ipsec/peers/1/*P/1" : "/api/ipsec/certs/1") + if (kind === "cert") expect(route.request().postDataJSON()).toEqual({ name: "cert-test" }) + expect(url.searchParams.get("force")).toBe(attempts === 5 && canForce ? "true" : null) + if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Удаление запрещено тест" } }) } + if (attempts === 2) return route.fulfill({ json: {} }) + if (attempts === 3) return route.fulfill({ json: { ok: false } }) + if (attempts === 4 && canForce) return route.fulfill({ status: 409, json: { error: "Объект используется тест" } }) + return route.fulfill({ json: { ok: true } }) + } + return route.fulfill({ json: path === "/api/auth/config" ? { required: false } + : path === "/api/ipsec" ? { servers: [{ serverId: "1", serverName: "Роутер", initialized: true, ike2Ready: true, natRuleManaged: true, clientsTotal: 1, clientsOnline: 0, peers: [peer], certs: [cert] }], clients: [client] } + : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} }) + }) + await page.goto("/ipsec") + if (kind !== "user") await page.getByRole("tab", { name: "Сервер", exact: true }).click() + const label = { user: "Удалить клиента", server: "Удалить IKEv2-сервер", peer: "Удалить peer", cert: "Удалить сертификат" }[kind] + await page.getByRole("button", { name: label, exact: true }).click() + const dialog = page.getByRole("alertdialog") + const remove = dialog.getByRole("button", { name: "Удалить", exact: true }) + await remove.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + await expect.poll(() => attempts).toBe(1) + await expect(dialog.getByRole("status")).toContainText("Ожидание результата") + await expect(dialog.getByRole("button", { name: "Отмена" })).toBeDisabled() + await page.keyboard.press("Escape") + await expect(dialog).toBeVisible() + release!() + await expect(dialog.getByRole("alert")).toContainText("Удаление запрещено тест") + for (let i = 2; i <= 3; i++) { + await remove.click() + await expect.poll(() => attempts).toBe(i) + await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил удаление") + await expect(page.getByText("Удалено", { exact: true })).toHaveCount(0) + } + await remove.click() + if (canForce) { + await expect(dialog.getByRole("alert")).toContainText("Объект используется тест") + await dialog.getByRole("button", { name: "Удалить принудительно", exact: true }).evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + } + await expect(dialog).toHaveCount(0) + expect(attempts).toBe(canForce ? 5 : 4) + await page.getByRole("button", { name: label, exact: true }).click() + await expect(dialog.getByRole("alert")).toHaveCount(0) + await expect(dialog.getByRole("button", { name: "Удалить принудительно" })).toHaveCount(0) + }) +}