From 61950638bc284534236bd199e1710b08ea8fb09c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 21 Sep 2026 15:00:13 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=83=D1=87=D0=B8=D1=82=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=81=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/servers/page.tsx | 34 +++++++--- .../data-grids/server-expanded-detail.tsx | 5 +- components/data-grids/servers-data-grid.tsx | 18 +++-- docs/ux-ui-progress.md | 12 +++- docs/ux-ui-stage-7.md | 2 +- tests/e2e/servers.spec.ts | 68 +++++++++++++++++++ 6 files changed, 121 insertions(+), 18 deletions(-) diff --git a/app/(main)/servers/page.tsx b/app/(main)/servers/page.tsx index b671e7da..1a6c3f92 100644 --- a/app/(main)/servers/page.tsx +++ b/app/(main)/servers/page.tsx @@ -2,6 +2,7 @@ import { Suspense, useEffect, useMemo, useRef, useState } from "react" import Link from "next/link" +import { can } from "@/lib/auth" import { useSearchParams } from "next/navigation" import { serverCreateSchema } from "@mmapp/contracts/servers" import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations" @@ -256,6 +257,13 @@ function ServersPageContent() { const formVersion = useRef(0) const testVersion = useRef(0) const contextVersion = useRef(0) + const canWrite = can("mm:servers:write") + const accessDenied = "Недостаточно прав: изменение и опрос серверов требуют разрешения на запись." + function requireWrite(report: (message: string) => void = setOperationError) { + if (can("mm:servers:write")) return true + report(accessDenied) + return false + } const controlsBusy = saving || rowBusy || pollAllBusy || pollingIds.size > 0 const set = (k: K, v: FormState[K]) => { @@ -316,6 +324,7 @@ function ServersPageContent() { // ── actions ────────────────────────────────────────────────────────────── function openAdd() { + if (!requireWrite()) return if (gate.current.has("save")) return formVersion.current++; testVersion.current++ setFormError(""); setDetailsLoading(false); setDetailsReady(true) @@ -326,6 +335,7 @@ function ServersPageContent() { } async function openEdit(s: Server) { + if (!requireWrite()) return if (gate.current.has("save")) return const version = ++formVersion.current testVersion.current++ @@ -370,6 +380,7 @@ function ServersPageContent() { } async function handleSave() { + if (!requireWrite(setFormError)) return if (!detailsReady || detailsLoading || !gate.current.acquire("save")) return const context = contextVersion.current setSaving(true); setFormError("") @@ -462,6 +473,7 @@ function ServersPageContent() { } async function handleToggleStatus(id: string) { + if (!requireWrite()) return const server = serverList.find(s => s.id === id) if (!server || !gate.current.acquire("rows")) return const context = contextVersion.current @@ -478,6 +490,7 @@ function ServersPageContent() { } async function handleDelete(id: string) { + if (!requireWrite(setDeleteError)) return if (!gate.current.acquire("rows")) return const context = contextVersion.current setDeleting(true); setRowBusy(true); setDeleteError("") @@ -500,12 +513,14 @@ function ServersPageContent() { } async function runPoll(ids: string[]) { + if (!requireWrite()) return if (!isLive || !gate.current.acquire("rows")) return const context = contextVersion.current setPollAllBusy(true); setPollingIds(new Set(ids)); setOperationError(""); setOperationMessage(""); setPollResults([]) try { const results = await pollServers(ids, async id => { if (context !== contextVersion.current) throw new Error("Контекст изменился; опрос отменён.") + if (!can("mm:servers:write")) throw new Error(accessDenied) await pollServer(backendUrl, id) }) if (context !== contextVersion.current) return @@ -522,6 +537,7 @@ function ServersPageContent() { } async function handleTest() { + if (!requireWrite(setFormError)) return if (!detailsReady || gate.current.has("save") || gate.current.has("test")) return const version = ++testVersion.current if (!form.host || !form.username) { @@ -595,11 +611,11 @@ function ServersPageContent() { crumbs={[{ label: "Управление" }, { label: "Серверы" }]} actions={ <> - - + } /> @@ -607,6 +623,7 @@ function ServersPageContent() {
+ {!canWrite && Только просмотрИзменение, удаление, опрос серверов и тест соединения требуют разрешения на запись. Просмотр деталей и фильтры доступны.} {operationError && Действие не завершено{operationError}} {operationMessage && {operationMessage}} {pollResults.length > 0 && ( @@ -614,7 +631,7 @@ function ServersPageContent() { Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length}
    {pollResults.map(r =>
  • {serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}
  • )}
- {pollResults.some(r => r.error) && } + {pollResults.some(r => r.error) && }
)} @@ -692,9 +709,10 @@ function ServersPageContent() { isLive={isLive} pollingIds={pollingIds} busy={controlsBusy} + canWrite={canWrite} onPoll={handlePoll} onEdit={openEdit} - onDelete={id => { setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }} + onDelete={id => { if (!requireWrite()) return; setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }} onToggleStatus={handleToggleStatus} /> @@ -714,7 +732,7 @@ function ServersPageContent() { {!detailsReady && !detailsLoading && editingId && }
} -
+
@@ -875,7 +893,7 @@ function ServersPageContent() {
) : ( - )} @@ -952,7 +970,7 @@ function ServersPageContent() { {deleteError &&

{deleteError}

} - + diff --git a/components/data-grids/server-expanded-detail.tsx b/components/data-grids/server-expanded-detail.tsx index 5193a7b2..00b6877b 100644 --- a/components/data-grids/server-expanded-detail.tsx +++ b/components/data-grids/server-expanded-detail.tsx @@ -22,11 +22,12 @@ const ROS_FEATURES = [ interface ServerExpandedDetailProps { server: Server isLive: boolean + canWrite: boolean isPolling: boolean onPoll: () => void } -function ServerExpandedDetail({ server: s, isLive, isPolling, onPoll }: ServerExpandedDetailProps) { +function ServerExpandedDetail({ server: s, isLive, isPolling, canWrite, onPoll }: ServerExpandedDetailProps) { const ver = rosVer(s.os) return ( @@ -65,7 +66,7 @@ function ServerExpandedDetail({ server: s, isLive, isPolling, onPoll }: ServerEx variant="outline" size="sm" className="h-7 gap-1.5 text-xs shrink-0" - disabled={isPolling} + disabled={!canWrite || isPolling} onClick={(e) => { e.stopPropagation(); onPoll() }} > diff --git a/components/data-grids/servers-data-grid.tsx b/components/data-grids/servers-data-grid.tsx index 28be4882..79832a3b 100644 --- a/components/data-grids/servers-data-grid.tsx +++ b/components/data-grids/servers-data-grid.tsx @@ -84,6 +84,7 @@ function RosBadge({ os }: { os: string }) { } interface ServerRowActionsProps { + canWrite: boolean busy: boolean server: Server isLive: boolean @@ -95,6 +96,7 @@ interface ServerRowActionsProps { } function ServerRowActions({ + canWrite, busy, server, isLive, @@ -137,22 +139,22 @@ function ServerRowActions({ Открыть WebFig - onEdit(server)}> + onEdit(server)}> Редактировать {isLive && ( - onPoll(server.id)} disabled={isPolling}> + onPoll(server.id)} disabled={!canWrite || isPolling}> {isPolling ? "Опрос…" : "Опросить"} )} - onToggleStatus(server.id)}> + onToggleStatus(server.id)}> {server.enabled ? "Отключить" : "Включить"} - onDelete(server.id)}> + onDelete(server.id)}> Удалить @@ -164,6 +166,7 @@ function ServerRowActions({ interface ServersDataGridProps { hasUnfilteredRows?: boolean + canWrite: boolean busy?: boolean servers: Server[] isLive: boolean @@ -175,6 +178,7 @@ interface ServersDataGridProps { } function ServersDataGrid({ + canWrite, hasUnfilteredRows = false, busy = false, servers, @@ -224,6 +228,7 @@ function ServersDataGrid({ cellClassName: DATA_GRID_CELL_PAD_FIRST, expandedContent: (row: Server) => ( } title={hasUnfilteredRows ? "Нет совпадений" : "Нет серверов"} - description={hasUnfilteredRows ? "Измените поиск или сбросьте фильтры" : "Добавьте первый MikroTik-сервер для мониторинга"} + description={hasUnfilteredRows ? "Измените поиск или сбросьте фильтры" : canWrite ? "Добавьте первый MikroTik-сервер для мониторинга" : "Серверов нет. Добавление доступно пользователю с правом записи."} className="border-0 py-12" /> ) diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index c8a27a01..a91c561f 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -21,7 +21,7 @@ | 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже | | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | -| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: блоки 7.1–7.2 завершены; далее права действий 7.3 | +| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2 и серверная часть 7.3 завершены; далее права мониторинга | | 8 | Трафик и статистика | Не начат | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | @@ -29,6 +29,16 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 7.3А — права действий серверов (2026-09-21) + +- ServersPage использует существующий can("mm:servers:write") после AuthGuard. Backend относит создание/изменение/удаление, POST poll и test-connection к записи; политика API не изменена. Наследование write/admin и auth disabled сохранены, is_admin не даёт отдельного обхода. +- Режим «Только просмотр»: отключены добавление, редактирование, включение, удаление, опрос в заголовке/меню/деталях и повтор опроса. Детали, фильтры и WebFig доступны. Пустой список не предлагает читателю добавить сервер. +- Обработчики повторно проверяют актуальные claims перед действием; массовый опрос — перед каждым запросом. Отзыв права не закрывает форму и не отправляет запись. Проверка в UI не заменяет backend; неизвестный клиенту отзыв сервер возвращает как 403 с сохранением формы. Внешняя смена token отражается на следующем render/действии; подписка на изменения сессии не добавлена. +- Проверки: 32 E2E servers.spec.ts прошли (27 прежних и 5 прав), TypeScript и профильный ESLint прошли. Production build прошёл: 34 статические страницы. ReUI audit: композиция, установленный Data Grid и тема сохранены; добавлены только состояние прав, props прикладных компонентов и сообщение Alert. +- Границы: HTTP-моки и тестовые claims без реальной JWT-проверки; RouterOS не проверялся. Известная кнопка «Экспорт» серверов без обработчика остаётся задачей форм/действий 7.4. +- Следующий шаг: 7.3Б — инвентаризация write-действий uptime (Ping/Resources/Speed), сопоставление с backend и ограничения прав, затем профильные HTTP-мок тесты. 7.3 целиком пока не завершён. +- Лимит после основного блока: использовано 15% пятичасового, 79% недельного. Локальный коммит без push; исходные изменения навыков/AGENTS/.codex не включать. + ## Этап 0 — исходная база Исходный HEAD: f8e743721007e809b8b148bb6ddcf9e6adb3f52d. diff --git a/docs/ux-ui-stage-7.md b/docs/ux-ui-stage-7.md index a9e1f406..3252b6c8 100644 --- a/docs/ux-ui-stage-7.md +++ b/docs/ux-ui-stage-7.md @@ -61,7 +61,7 @@ URL — источник сохранённого состояния. Back/Forwa 3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие. 4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении. -Следующий исполняемый шаг: блок 7.3 — права действий серверов и мониторинга. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии. +Следующий исполняемый шаг: блок 7.3Б — права действий мониторинга. Серверная часть 7.3А выполнена: can(mm:servers:write), ограничения кнопок, проверки обработчиков и 5 E2E прав. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии. ## Решение по синхронизации URL (7.1) diff --git a/tests/e2e/servers.spec.ts b/tests/e2e/servers.spec.ts index 0e256897..104644f6 100644 --- a/tests/e2e/servers.spec.ts +++ b/tests/e2e/servers.spec.ts @@ -441,3 +441,71 @@ test("URL серверов: черновик не публикуется, быс await expect(search).toHaveValue("192.0.2.2") await expect(page.locator("tr[data-row-id]")).toHaveCount(1) }) + +// Claims are UI fixtures only; HTTP mocks do not verify JWT signatures. +function permissionToken(permission: string, isAdmin = false) { + return `fixture.${Buffer.from(JSON.stringify({ sub: "test", apps: ["mm"], permissions: [permission], is_admin: isAdmin, exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url")}.fixture` +} + +for (const isAdmin of [false, true]) { + test(`права серверов: read, is_admin=${isAdmin}, только просмотр без записи`, async ({ page }) => { + await page.addInitScript(token => localStorage.setItem("mmapp_token", token), permissionToken("mm:servers:read", isAdmin)) + const writes: string[] = [] + await setup(page, async (route, path) => { + if (path === "/api/auth/config") { await route.fulfill({ json: { required: true } }); return true } + if (path.startsWith("/api/servers") && route.request().method() !== "GET") writes.push(path) + return false + }) + await expect(page.getByText("Только просмотр", { exact: true })).toBeVisible() + await expect(page.getByRole("button", { name: "Добавить сервер", exact: true })).toBeDisabled() + await expect(page.getByRole("button", { name: "Проверить все", exact: true })).toBeDisabled() + await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click() + for (const name of ["Редактировать", "Опросить", "Отключить", "Удалить"]) { + await expect(page.getByRole("menuitem", { name, exact: true })).toBeDisabled() + } + await expect(page.getByRole("menuitem", { name: "Открыть WebFig", exact: true })).toBeEnabled() + await page.keyboard.press("Escape") + await page.getByRole("button", { name: "Раскрыть: Проверка 1", exact: true }).click() + await expect(page.getByText("Модель: Test router", { exact: true })).toBeVisible() + await expect(page.getByRole("button", { name: "Опросить сейчас", exact: true })).toBeDisabled() + expect(writes).toEqual([]) + }) +} + +for (const permission of ["mm:servers:write", "mm:servers:admin"]) { + test(`права серверов: ${permission}, серверный 403 сохраняет форму`, async ({ page }) => { + await page.addInitScript(token => localStorage.setItem("mmapp_token", token), permissionToken(permission)) + await setup(page, async (route, path) => { + if (path === "/api/auth/config") { await route.fulfill({ json: { required: true } }); return true } + if (path === "/api/servers/1" && route.request().method() === "PUT") { + await route.fulfill({ status: 403, json: { error: "Forbidden" } }); return true + } + return false + }) + await expect(page.getByRole("button", { name: "Добавить сервер", exact: true })).toBeEnabled() + await expect(page.getByRole("button", { name: "Проверить все", exact: true })).toBeEnabled() + await edit(page) + await finalStep(page) + await page.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(page.getByRole("dialog")).toBeVisible() + await expect(page.getByRole("alert")).toContainText("Недостаточно прав") + }) +} + +test("права серверов: отзыв перед сохранением блокирует запрос и сохраняет форму", async ({ page }) => { + await page.addInitScript(token => localStorage.setItem("mmapp_token", token), permissionToken("mm:servers:write")) + let writes = 0 + await setup(page, async (route, path) => { + if (path === "/api/auth/config") { await route.fulfill({ json: { required: true } }); return true } + if (path.startsWith("/api/servers") && route.request().method() !== "GET") writes++ + return false + }) + await edit(page) + await finalStep(page) + await page.evaluate(token => localStorage.setItem("mmapp_token", token), permissionToken("mm:servers:read")) + await page.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(page.getByRole("dialog")).toBeVisible() + await expect(page.getByRole("alert")).toContainText("Недостаточно прав") + await expect(page.getByRole("button", { name: "Сохранить", exact: true })).toBeDisabled() + expect(writes).toBe(0) +})