+ {error && {error}
}
+ {loading && Загрузка настроек ACME…
}
+ {!confirmed && Настройки ACME пока неизвестны. Сохранение и проверка недоступны до успешного чтения.
}
+ {!liveReady && Нет подключения к API.
}
+ {!canRead && Нет права mm:certificates:read.
}
+ {!canWrite && Настройки ACME доступны только для чтения.
}
+ {confirmed ? confirmed.tokenConfigured ? "Токен сохранён на backend." : "Сохранённого токена нет." : "Состояние токена неизвестно."}
+ {testResult && {testResult}. Настройки не изменены.
}
+ {liveReady && canRead && }
+
+ {canWrite &&
+
+
+
}
+
+ )
+}
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index cc70ec16..0d7eaa62 100644
--- a/docs/ux-ui-plan.md
+++ b/docs/ux-ui-plan.md
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
-Актуальная точка продолжения: 11А.20 — достоверная форма ACME, затем checkpoint/polling выпуска. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
+Актуальная точка продолжения: 11А.21 — совместимость issue DTO, затем checkpoint/polling выпуска. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 2c55a5dc..aa43f0c0 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
-| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.19; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew готовы; далее 11А.20 |
+| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.20; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.21 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
+## Блок 11А.20 — достоверная форма ACME (2026-10-01)
+
+- ACME форма выделена в собственный компонент с прежней OpsPanel/ReUI композицией. До подтверждённого GET настройки и состояние токена неизвестны; read-only читает фактические значения, сохранение/тест требуют write права.
+- Dirty URL/zone/token сохраняются при фоновом чтении и отказе PUT. Токен очищается только после валидного подтверждения; tokenConfigured=false при отправленном токене не считается успехом. Пустое поле сохраняет прежний секрет на backend.
+- Save/test имеют общий синхронный lock и блокируют поля на время операции; test не объявляется сохранением. Ошибка Cloudflare видна с причиной. При unmount запросы отменяются, старые ответы/toast не попадают в другой источник.
+- 10 ACME E2E, 2 поздних PUT context E2E, 2 read-only E2E, TypeScript/ESLint и production frontend прошли.
+- Следующий блок 11А.21: совместимость реального issue DTO (backend serverId сейчас числом, failed step не включён в контракт), затем сохранение job checkpoint и отмена polling без повторного POST. Выпуск/общая приёмка сертификатов, backups/11Б/12 остаются незавершёнными.
+
## Блок 11А.19 — достоверные настройки автообновления (2026-10-01)
- Renew форма начинает с unknown, а не enabled/default расписания. Ошибка чтения оставляет операции заблокированными до подтверждённого GET; есть явный повтор и отдельные read/write права. Read-only не выдаётся за недоступный backend.
diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md
index edf40b8f..63f3f4bc 100644
--- a/docs/ux-ui-stage-11a.md
+++ b/docs/ux-ui-stage-11a.md
@@ -234,3 +234,11 @@
- Фоновый GET не стирает dirty черновик; запросы отменяются при unmount, поздний ответ не меняет другой источник и не показывает старый toast. Disabled MM не объявляется доказательством включённого RouterOS ACME.
- 10 renew E2E, повторные 12 data E2E, 2 context E2E позднего PUT, TypeScript/ESLint и production frontend прошли. HTTP fixtures приведены к реальному renewBeforeDays контракту.
- Следующий блок 11А.20: выделить достоверную форму ACME, неизвестное чтение, сохранение черновика/токена, общий lock save/test и отмена; затем issue checkpoint/polling и приёмка сертификатов.
+
+## Блок 11А.20 — достоверная форма ACME (2026-10-01)
+
+- ACME форма выделена в собственный компонент с прежней OpsPanel/ReUI композицией. До подтверждённого GET настройки и состояние токена неизвестны; read-only читает фактические значения, сохранение/тест требуют write права.
+- Dirty URL/zone/token сохраняются при фоновом чтении и отказе PUT. Токен очищается только после валидного подтверждения; tokenConfigured=false при отправленном токене не считается успехом. Пустое поле сохраняет прежний секрет на backend.
+- Save/test имеют общий синхронный lock и блокируют поля на время операции; test не объявляется сохранением. Ошибка Cloudflare видна с причиной. При unmount запросы отменяются, старые ответы/toast не попадают в другой источник.
+- 10 ACME E2E, 2 поздних PUT context E2E, 2 read-only E2E, TypeScript/ESLint и production frontend прошли.
+- Следующий блок 11А.21: совместимость реального issue DTO (backend serverId сейчас числом, failed step не включён в контракт), затем сохранение job checkpoint и отмена polling без повторного POST. Выпуск/общая приёмка сертификатов, backups/11Б/12 остаются незавершёнными.
diff --git a/tests/e2e/certificates-acme.spec.ts b/tests/e2e/certificates-acme.spec.ts
new file mode 100644
index 00000000..1cba7abd
--- /dev/null
+++ b/tests/e2e/certificates-acme.spec.ts
@@ -0,0 +1,61 @@
+import { test, expect, type Page } from "@playwright/test"
+import { certificateState, server } from "./fixtures/certificates"
+const acme = { directoryUrl: "https://acme.example.test/directory", defaultZoneId: "old-zone", tokenConfigured: true }
+const directory = (page: Page) => page.getByRole("textbox", { name: "ACME directory URL", exact: true })
+const token = (page: Page) => page.getByLabel("Cloudflare API token", { exact: true })
+const save = (page: Page) => page.getByRole("button", { name: "Сохранить настройки", exact: true })
+const check = (page: Page) => page.getByRole("button", { name: "Проверить Cloudflare", exact: true })
+test.beforeEach(async ({ page }) => {
+ await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
+ await page.route("**/api/**", r => {
+ const path = new URL(r.request().url()).pathname
+ return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificateState() : path === "/api/certificates/renew-settings" ? { enabled: false, intervalSec: 300, renewBeforeDays: 30 } : acme })
+ })
+})
+for (const malformed of [false, true]) test("ACME: неизвестные настройки после отказа " + malformed, async ({ page }) => {
+ let fail = true; await page.route("**/api/certificates/acme-settings", r => r.fulfill(fail ? malformed ? { json: {} } : { status: 503, json: { error: "ACME чтение отказало" } } : { json: acme }))
+ await page.goto("/certificates"); await expect(page.getByRole("alert", { name: "Настройки ACME", exact: true })).toBeVisible()
+ await expect(save(page)).toBeDisabled(); await expect(check(page)).toBeDisabled(); await expect(directory(page)).toHaveValue("")
+ await expect(page.getByText("Состояние токена неизвестно.", { exact: true })).toBeVisible()
+ fail = false; await page.getByRole("button", { name: "Перечитать настройки ACME", exact: true }).click(); await expect(directory(page)).toHaveValue(acme.directoryUrl)
+})
+for (const kind of ["API", "malformed", "token-unconfirmed"]) test("ACME: отказ сохранения " + kind + " оставляет черновик и токен", async ({ page }) => {
+ await page.route("**/api/certificates/acme-settings", r => r.request().method() === "GET" ? r.fulfill({ json: acme }) : r.fulfill(kind === "API" ? { status: 503, json: { error: "Сохранение ACME отказало" } } : { json: kind === "malformed" ? {} : { ...acme, tokenConfigured: false } }))
+ await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl)
+ await directory(page).fill("https://new.example.test/directory"); await token(page).fill("test-draft-token"); await save(page).click()
+ await expect(page.getByRole("alert", { name: "Настройки ACME", exact: true })).toContainText("Результат сохранения не подтверждён")
+ await expect(token(page)).toHaveValue("test-draft-token"); await expect(directory(page)).toHaveValue("https://new.example.test/directory")
+ await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toHaveCount(0)
+})
+test("ACME: подтверждённое сохранение очищает только принятый токен", async ({ page }) => {
+ let body: Record