fix(ui): подтверждать удаление объектов IPsec
This commit is contained in:
1 parent
74e7cd257d
commit
611f31875e
6 files changed
+113
-49
No files matched your search
+22
-41
@@ -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<PendingDelete | null>(null)
|
||||
const deleteLock = useRef(false)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
const [forceAllowed, setForceAllowed] = useState(false)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||
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<unknown>) => {
|
||||
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}
|
||||
/>
|
||||
|
||||
<AlertDialog open={pendingDelete != null} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
|
||||
<AlertDialog open={pendingDelete != null} onOpenChange={(v) => { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null); setForceAllowed(false) } }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
@@ -864,9 +842,12 @@ export default function IpsecPage() {
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>{deleteDialogTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{deleteDialogDescription}</AlertDialogDescription>
|
||||
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание результата удаления…</p>}
|
||||
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>
|
||||
{forceAllowed && <Button variant="destructive" disabled={busy} onClick={() => void confirmDelete(true)}>Удалить принудительно</Button>}
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={busy}
|
||||
|
||||
+2
-2
@@ -80,7 +80,7 @@ flowchart LR
|
||||
|
||||
## Текущая точка продолжения
|
||||
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.16 — история, восстановление, формы и экспорт IPsec); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.17 — история, формы, экспорт и удаление IPsec); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
|
||||
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.md).
|
||||
|
||||
@@ -88,7 +88,7 @@ flowchart LR
|
||||
|
||||
Ближайший оставшийся объём:
|
||||
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. Далее — удаление объектов IPsec и подтверждение API, затем GRE/VXLAN/Firewall/Filters.
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. Далее — редактирование peer IPsec, затем GRE/VXLAN/Firewall/Filters.
|
||||
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||
|
||||
|
||||
+10
-1
@@ -25,12 +25,21 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.16 — история, восстановление, формы и экспорт IPsec |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.17 — история, формы, экспорт и удаление IPsec |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.17 — удаление объектов IPsec (2026-09-29)
|
||||
|
||||
- Все четыре API удаления (клиент, сервер, peer, сертификат) требуют ok=true. Пустой ответ и ok=false не вызывают успех и не закрывают подтверждение.
|
||||
- Общий диалог сохраняет объект и ошибку для повтора, объявляет ожидание; синхронный замок предотвращает повтор и закрытие до завершения. После успешного подтверждения диалог закрывается и список обновляется.
|
||||
- При 409 для peer/сертификата принудительное удаление доступно отдельной кнопкой в том же диалоге вместо действия в toast. Оно использует тот же замок и проверку ответа, force=true отправляется только после явного нажатия; backend-правила не менялись.
|
||||
- Все 4 новых E2E прошли: задержка, двойное нажатие, Escape, 403, пустой ответ, ok=false, успех; для peer/сертификата также 409 и принудительный повтор. Повторное открытие не сохраняет старую ошибку. Lint изменённых файлов, TypeScript и diff-check прошли. Build не повторялся, последний — 9.16.
|
||||
- Далее: редактирование peer IPsec (handleEditPeer/IpsecPeerSheet/patchIpsecPeer) — подтверждение API, сохранение полей, блокировка повторов и закрытия. Затем оставшаяся карта этапа 9. Реальные RouterOS/JWT и последствия принудительного удаления не проверены моками.
|
||||
- После E2E использовано 81% пятичасовой и 90% недельной квоты. Блок начат при 74%, завершён ниже резерва; новый блок не начат. Локальный коммит без push; чужие изменения сохранены.
|
||||
|
||||
## Блок 9.16 — экспорт сертификатов IPsec (2026-09-29)
|
||||
|
||||
- Оба API экспорта проверяют схему бандла, непустые имя/файл/содержимое, синтаксис Base64 и наличие пароля архива. Это проверка транспортного ответа, не криптографическая проверка PKCS#12. Начальный пароль генерируется crypto.randomUUID вместо Math.random.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Этап 9: VPN и сетевые операции
|
||||
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.16. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.17. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
|
||||
## Схема и референсы принятого плана
|
||||
|
||||
@@ -105,3 +105,7 @@ IPsec/GRE/Firewall/Filters/рекурсивные маршруты требую
|
||||
## Блок 9.16 — сертификаты IPsec (2026-09-29)
|
||||
|
||||
Экспорт по identity/имени проверяет бандл, пароль и Base64; закрытая панель не принимает поздние ответы. Ошибка сохраняет панель для повтора; прежний архив сохраняет свой пароль при ошибке нового экспорта. Пароль только для чтения, скачивание заблокировано при запросе. Все 6 E2E, lint и TypeScript прошли; build — в журнале. Далее — удаление объектов IPsec. Реальный PKCS#12/RouterOS не проверен, сетевой запрос после закрытия не отменяется.
|
||||
|
||||
## Блок 9.17 — удаление IPsec (2026-09-29)
|
||||
|
||||
Удаление клиента/сервера/peer/сертификата требует ok=true; диалог сохраняется при ошибке, блокирует повтор и закрытие до ответа. Принудительное удаление после 409 перенесено из toast в тот же диалог с общей защитой. Все 4 E2E прошли; lint/TypeScript — в журнале. Далее — редактирование peer IPsec. Реальные сетевые последствия не проверены, последний build — 9.16.
|
||||
+12
-4
@@ -44,11 +44,13 @@ export async function deleteIpsecServer(
|
||||
const q = new URLSearchParams()
|
||||
if (opts?.removeCertificates === false) q.set("removeCertificates", "false")
|
||||
const suffix = q.size > 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
|
||||
}
|
||||
@@ -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<void>(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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user