fix(ui): учитывать права на операции с серверами

This commit is contained in:
Denozordec committed 2026-09-21 15:00:13 +07:00
1 parent e9e5030047
commit 61950638bc
6 files changed
+121 -18

No files matched your search

+26 -8
View File
@@ -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 extends keyof FormState>(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={
<>
<Button variant="outline" size="sm" onClick={() => selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
<Button variant="outline" size="sm" onClick={() => selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!canWrite || !isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />{selectedServerId !== null ? "Проверить сервер" : "Проверить все"}
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm" onClick={openAdd} disabled={controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
<Button size="sm" onClick={openAdd} disabled={!canWrite || controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
</>
}
/>
@@ -607,6 +623,7 @@ function ServersPageContent() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{!canWrite && <Alert variant="info" role="status"><AlertTitle>Только просмотр</AlertTitle><AlertDescription>Изменение, удаление, опрос серверов и тест соединения требуют разрешения на запись. Просмотр деталей и фильтры доступны.</AlertDescription></Alert>}
{operationError && <Alert variant="destructive" role="alert"><AlertTitle>Действие не завершено</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
{operationMessage && <Alert variant="success" role="status"><AlertDescription>{operationMessage}</AlertDescription></Alert>}
{pollResults.length > 0 && (
@@ -614,7 +631,7 @@ function ServersPageContent() {
<AlertTitle>Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length}</AlertTitle>
<AlertDescription>
<ul>{pollResults.map(r => <li key={r.id}>{serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}</li>)}</ul>
{pollResults.some(r => r.error) && <Button type="button" variant="outline" size="sm" disabled={controlsBusy} onClick={() => runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные</Button>}
{pollResults.some(r => r.error) && <Button type="button" variant="outline" size="sm" disabled={!canWrite || controlsBusy} onClick={() => runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные</Button>}
</AlertDescription>
</Alert>
)}
@@ -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}
/>
</DataPageCard>
@@ -714,7 +732,7 @@ function ServersPageContent() {
{!detailsReady && !detailsLoading && editingId && <Button type="button" variant="outline" onClick={() => { const server = serverList.find(s => s.id === editingId); if (server) void openEdit(server) }}>Повторить загрузку</Button>}
</div>}
<fieldset disabled={saving || !detailsReady} className="flex min-h-0 flex-1 flex-col" aria-busy={saving || detailsLoading}>
<fieldset disabled={!canWrite || saving || !detailsReady} className="flex min-h-0 flex-1 flex-col" aria-busy={saving || detailsLoading}>
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
@@ -875,7 +893,7 @@ function ServersPageContent() {
<div className="flex flex-col gap-2">
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
disabled={testState === "testing"} onClick={handleTest}>
disabled={!canWrite || testState === "testing"} onClick={handleTest}>
{testState === "testing"
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
: <><WifiIcon className="size-4" />Проверить подключение</>}
@@ -936,7 +954,7 @@ function ServersPageContent() {
{sheetStep < 4 ? (
<Button className="ml-auto" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" disabled={saving || !detailsReady || testState === "testing"} onClick={handleSave}>
<Button className="ml-auto" disabled={!canWrite || saving || !detailsReady || testState === "testing"} onClick={handleSave}>
{saving ? "Сохранение…" : sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
)}
@@ -952,7 +970,7 @@ function ServersPageContent() {
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
<AlertDialogFooter>
<Button type="button" variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>Отмена</Button>
<Button type="button" variant="destructive" disabled={deleting} onClick={() => { if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}</Button>
<Button type="button" variant="destructive" disabled={!canWrite || deleting} onClick={() => { if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -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() }}
>
<RefreshCwIcon className={cn("size-3.5", isPolling && "animate-spin")} />
+12 -6
View File
@@ -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({
<ExternalLinkIcon className="size-4" />
Открыть WebFig
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(server)}>
<DropdownMenuItem disabled={!canWrite} onClick={() => onEdit(server)}>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
{isLive && (
<DropdownMenuItem onClick={() => onPoll(server.id)} disabled={isPolling}>
<DropdownMenuItem onClick={() => onPoll(server.id)} disabled={!canWrite || isPolling}>
<RefreshCwIcon className={cn("size-4", isPolling && "animate-spin")} />
{isPolling ? "Опрос…" : "Опросить"}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onToggleStatus(server.id)}>
<DropdownMenuItem disabled={!canWrite} onClick={() => onToggleStatus(server.id)}>
<PowerIcon className="size-4" />
{server.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDelete(server.id)}>
<DropdownMenuItem disabled={!canWrite} variant="destructive" onClick={() => onDelete(server.id)}>
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
@@ -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) => (
<ServerExpandedDetail
canWrite={canWrite}
server={row}
isLive={isLive}
isPolling={pollingIds.has(row.id)}
@@ -346,6 +351,7 @@ function ServersDataGrid({
const s = row.original
return (
<ServerRowActions
canWrite={canWrite}
busy={busy}
server={s}
isLive={isLive}
@@ -365,7 +371,7 @@ function ServersDataGrid({
},
},
],
[busy, isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
[canWrite, busy, isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
)
const table = useTable({
@@ -382,7 +388,7 @@ function ServersDataGrid({
<EmptyState
icon={<ServerIcon className="size-4" />}
title={hasUnfilteredRows ? "Нет совпадений" : "Нет серверов"}
description={hasUnfilteredRows ? "Измените поиск или сбросьте фильтры" : "Добавьте первый MikroTik-сервер для мониторинга"}
description={hasUnfilteredRows ? "Измените поиск или сбросьте фильтры" : canWrite ? "Добавьте первый MikroTik-сервер для мониторинга" : "Серверов нет. Добавление доступно пользователю с правом записи."}
className="border-0 py-12"
/>
)
+11 -1
View File
@@ -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.
+1 -1
View File
@@ -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)
+68
View File
@@ -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)
})