diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 0f591291..17970a7b 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -398,10 +398,6 @@ export default function IpsecPage() { setCertByName(null) setCertOpen(true) } - } catch (e) { - toast.error("Ошибка создания клиента", { - description: e instanceof Error ? e.message : String(e), - }) } finally { setBusy(false) } @@ -420,8 +416,6 @@ export default function IpsecPage() { setUserOpen(false) setEditing(null) await loadLive() - } catch (e) { - toast.error("Ошибка обновления", { description: e instanceof Error ? e.message : String(e) }) } finally { setBusy(false) } diff --git a/components/ipsec/ipsec-user-sheet.tsx b/components/ipsec/ipsec-user-sheet.tsx index e5501d80..6f316d92 100644 --- a/components/ipsec/ipsec-user-sheet.tsx +++ b/components/ipsec/ipsec-user-sheet.tsx @@ -1,9 +1,10 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import { IPSEC_MIN_PASSPHRASE, type IpsecClientDto } from "@mmapp/contracts/ipsec" -import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" +import { FormField, SectionTitle } from "@/components/form-kit" import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" import { Input } from "@/components/ui/input" import { Sheet, SheetContent, SheetHeader, SheetTitle, @@ -63,11 +64,19 @@ function IpsecUserSheet({ onSubmit: (form: IpsecUserFormState) => void | Promise }) { const [form, setForm] = useState(defaultIpsecUserForm) + const lock = useRef(false) + const opened = useRef(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const pending = busy || submitting const set = (k: K, v: IpsecUserFormState[K]) => setForm((f) => ({ ...f, [k]: v })) useEffect(() => { - if (!open) return + if (!open) { opened.current = false; return } + if (opened.current) return + opened.current = true + queueMicrotask(() => setError(null)) if (editing) { const edit = editing queueMicrotask(() => setForm({ @@ -89,7 +98,7 @@ function IpsecUserSheet({ const canSubmit = useMemo(() => { if (!editing && !form.serverId) return false if (!form.name.trim()) return false - if (!editing && form.authMethod === "pre-shared-key" && form.psk.trim().length < 8) return false + if (form.authMethod === "pre-shared-key" && (!editing || form.psk.trim()) && form.psk.trim().length < 8) return false if (form.useStaticIp && form.staticIp.trim() && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(form.staticIp.trim())) return false // RouterOS отклоняет export-passphrase короче 8 символов const passphrase = form.passphrase.trim() @@ -97,9 +106,19 @@ function IpsecUserSheet({ return true }, [form, editing]) + const submit = async () => { + if (!canSubmit || busy || lock.current) return + lock.current = true + setSubmitting(true) + setError(null) + try { await onSubmit(form) } + catch (error) { setError(error instanceof Error ? error.message : "Не удалось сохранить клиента") } + finally { lock.current = false; setSubmitting(false) } + } + return ( - - + { if (!lock.current && !busy) onOpenChange(value) }}> + {editing ? `Клиент «${editing.name}»` : "Новый клиент IKEv2"} @@ -110,14 +129,16 @@ function IpsecUserSheet({
+
Основные {!editing ? ( set("peerName", e.target.value)} > @@ -149,6 +171,7 @@ function IpsecUserSheet({ set("name", e.target.value)} /> @@ -187,6 +210,7 @@ function IpsecUserSheet({ set("psk", e.target.value)} /> @@ -196,6 +220,7 @@ function IpsecUserSheet({ set("remoteId", e.target.value)} /> @@ -215,6 +240,7 @@ function IpsecUserSheet({ set("passphrase", e.target.value)} /> @@ -233,31 +259,35 @@ function IpsecUserSheet({ {" "}Статический IP создаёт персональную identity на peer.

- set("useStaticIp", v)} /> + set("useStaticIp", v)} />
{form.useStaticIp && ( set("staticIp", e.target.value)} /> )} + + {pending &&

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

} + {error &&

{error}

} - }> + }> Отмена
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index df263fab..39e6a9fc 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -80,7 +80,7 @@ flowchart LR ## Текущая точка продолжения -По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.14 — история, восстановление и инициализация IPsec); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. +По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.15 — история, восстановление, инициализация и пользователи 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, сохранение формы и защита повторной отправки. Далее — создание/редактирование пользователей IPsec, затем сертификаты и 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 и сохранение формы. Далее — получение и повторный экспорт сертификатов IPsec, затем GRE/VXLAN/Firewall/Filters. 2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения. 3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index b851978c..fbc3635f 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -25,12 +25,19 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.14 — история, восстановление и инициализация IPsec | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.15 — история, восстановление, инициализация и пользователи IPsec | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.15 — создание и редактирование пользователей IPsec (2026-09-29) + +- Создание проверяет ipsecUserCreatedSchema, принадлежность клиента серверу, имя и метод аутентификации. Пустой ответ, client=null и чужой сервер не считаются успехом; редактирование требует ok=true. Backend иногда возвращает client=null после применения: UI сообщает отсутствие подтверждения, а не утверждает откат операции. +- Ошибка остаётся в форме с введёнными значениями. Синхронный замок защищает повторное нажатие, поля/переключатель и закрытие блокируются до ответа; ожидание объявляется через status. Начальные значения задаются один раз на открытие. При смене сервера сбрасывается peer; короткий новый PSK при редактировании блокирует отправку. Поля получили доступные имена. +- Все 7 E2E прошли: три новых сценария пользователей (сертификат, PSK, редактирование) и четыре регрессионных проверки инициализации/таблиц. Проверены задержка, двойное нажатие, 403, повреждённый/неподтверждённый ответ, сохранение ввода и успех. Lint изменённых файлов, TypeScript и diff-check чистые. Build не повторялся, последний — 9.13. +- Далее: сертификаты IPsec — openCert/openCertByName/reexportCert, проверка бандла, сохранение контекста и защита поздних ответов/повторов. Получение настоящего сертификата, RouterOS/JWT и транзакционность операций не проверены. Этап 9 открыт. Локальный коммит без push; чужие изменения сохранены. + ## Блок 9.14 — инициализация IPsec (2026-09-29) - Успех требует валидной сводки по contracts, совпадения serverId и initialized=true. Пустой ответ, неинициализированный сервер и чужое устройство не закрывают форму и не показывают успех. Backend возвращает сводку после применения; правила сетевой операции не изменялись. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 8c1ac02a..816b0cde 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -1,6 +1,6 @@ # Этап 9: VPN и сетевые операции -Статус: начат 2026-09-28, выполнены блоки 9.1–9.14. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. +Статус: начат 2026-09-28, выполнены блоки 9.1–9.15. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. ## Схема и референсы принятого плана @@ -97,3 +97,7 @@ IPsec/GRE/Firewall/Filters/рекурсивные маршруты требую ## Блок 9.14 — инициализация IPsec (2026-09-29) Проверяются схема сводки, serverId и initialized=true. Ошибка остаётся в форме с введёнными значениями; замок блокирует повтор, редактирование и закрытие во время запроса. Исправлен ввод IPv4, добавлены доступные имена. Новый E2E и 3 теста таблиц прошли, lint/TypeScript чистые. Далее — создание/редактирование пользователей IPsec. Реальные устройства и транзакционность сетевой операции не проверены; последний build — 9.13. + +## Блок 9.15 — пользователи IPsec (2026-09-29) + +Создание проверяет схему клиента, сервер, имя и метод; редактирование требует ok=true. Форма сохраняет ввод и ошибку, блокирует повторы/закрытие/поля до ответа; добавлены доступные имена, сброс peer при смене сервера и проверка нового PSK. Все 7 E2E, lint и TypeScript прошли. Далее — загрузка/повторный экспорт сертификатов и контекст запросов. Реальные устройства и выпуск сертификатов не проверены; последний build — 9.13. diff --git a/shared/api/ipsec.ts b/shared/api/ipsec.ts index 3d9db4aa..d6d93cf4 100644 --- a/shared/api/ipsec.ts +++ b/shared/api/ipsec.ts @@ -1,4 +1,4 @@ -import { ipsecServerSummaryDtoSchema } from "@mmapp/contracts/ipsec" +import { ipsecServerSummaryDtoSchema, ipsecUserCreatedSchema } from "@mmapp/contracts/ipsec" import { requestJson } from "@/shared/api/http-client" import type { IpsecCertBundle, @@ -55,10 +55,15 @@ export async function createIpsecUser( baseUrl: string, body: IpsecUserCreateRequest, ): Promise { - return requestJson(baseUrl, "/api/ipsec/users", { + const response = await requestJson(baseUrl, "/api/ipsec/users", { method: "POST", body: JSON.stringify(body), }) + const result = ipsecUserCreatedSchema.safeParse(response) + if (!result.success || result.data.client.serverId !== String(body.serverId) || result.data.client.name !== body.name.trim() || result.data.client.authMethod !== (body.authMethod ?? "certificate")) { + throw new Error("Сервер не подтвердил создание клиента") + } + return result.data } export async function patchIpsecUser( @@ -67,11 +72,13 @@ export async function patchIpsecUser( clientId: string, patch: IpsecUserPatch, ): Promise<{ ok: boolean }> { - return requestJson<{ ok: boolean }>( + const result = await requestJson<{ ok: boolean }>( baseUrl, `/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}`, { method: "PATCH", body: JSON.stringify(patch) }, ) + if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение клиента") + return result } export async function deleteIpsecUser( diff --git a/tests/e2e/ipsec-user.spec.ts b/tests/e2e/ipsec-user.spec.ts new file mode 100644 index 00000000..0d119ddd --- /dev/null +++ b/tests/e2e/ipsec-user.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "@playwright/test" + +for (const mode of ["certificate", "pre-shared-key", "edit"] as const) { + test(`IPsec: форма клиента ${mode} сохраняет ввод и требует подтверждения`, async ({ page }) => { + const editing = mode === "edit" + const authMethod = mode === "certificate" ? "certificate" : "pre-shared-key" + const client = { id: "client-1", rosId: "*P/1", serverId: "1", serverName: "Роутер", name: "alice", authMethod, online: false, disabled: false, managed: true } + let attempts = 0 + let release: (() => void) | undefined + const gate = new Promise(resolve => { release = resolve }) + await page.route("**/health", route => route.fulfill({ json: { ok: true } })) + await page.route("**/api/**", async route => { + const path = new URL(route.request().url()).pathname + if (route.request().method() === (editing ? "PATCH" : "POST") && path.startsWith("/api/ipsec/users")) { + attempts++ + expect(route.request().postDataJSON()).toMatchObject({ name: "bob", staticIp: "10.77.0.10" }) + if (editing) expect(decodeURIComponent(path)).toBe("/api/ipsec/users/1/*P/1") + else expect(route.request().postDataJSON()).toMatchObject({ serverId: "1", authMethod }) + if (authMethod === "pre-shared-key") expect(route.request().postDataJSON().psk).toBe("test-secret-123") + 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: editing ? { ok: false } : { client: null } }) + if (attempts === 4 && !editing) return route.fulfill({ json: { client: { ...client, name: "bob", serverId: "2" } } }) + return route.fulfill({ json: editing ? { ok: true } : { client: { ...client, name: "bob" } } }) + } + return route.fulfill({ json: path === "/api/auth/config" ? { required: false } + : path === "/api/ipsec" ? { servers: [], clients: editing ? [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") + await page.getByRole("button", { name: editing ? "alice" : "Клиент", exact: true }).click() + const sheet = page.getByRole("dialog") + if (!editing) { + await sheet.getByLabel("Сервер", { exact: true }).selectOption("1") + if (mode === "pre-shared-key") await sheet.getByRole("button", { name: "PSK", exact: true }).click() + } + await sheet.getByLabel("Имя клиента").fill("bob") + const submit = sheet.getByRole("button", { name: editing ? "Сохранить" : "Создать клиента", exact: true }) + if (authMethod === "pre-shared-key") { + await sheet.getByLabel("Secret (PSK)").fill("short") + await expect(submit).toBeDisabled() + await sheet.getByLabel("Secret (PSK)").fill("test-secret-123") + } + await sheet.getByRole("switch", { name: "Статический IP" }).click() + await sheet.getByLabel("IP клиента", { exact: true }).fill("10.77.0.10") + await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + await expect.poll(() => attempts).toBe(1) + await expect(sheet.getByRole("status")).toContainText("Ожидание результата") + await expect(sheet.getByLabel("Имя клиента")).toBeDisabled() + await expect(sheet.getByRole("switch")).toBeDisabled() + await expect(sheet.getByRole("button", { name: "Отмена" })).toBeDisabled() + await page.keyboard.press("Escape") + await page.mouse.click(10, 10) + await expect(sheet).toBeVisible() + release!() + await expect(sheet.getByRole("alert")).toContainText("Клиент запрещён тест") + await expect(sheet.getByLabel("Имя клиента")).toHaveValue("bob") + await expect(sheet.getByLabel("IP клиента", { exact: true })).toHaveValue("10.77.0.10") + for (let attempt = 2; attempt <= (editing ? 3 : 4); attempt++) { + await submit.click() + await expect.poll(() => attempts).toBe(attempt) + await expect(sheet.getByRole("alert")).toContainText("Сервер не подтвердил") + } + await submit.click() + await expect(sheet).toHaveCount(0) + expect(attempts).toBe(editing ? 4 : 5) + }) +}