From 02ca48ef9c8d0fe6ef457c9e46374856e147b60c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 09:39:39 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B8=20=D1=81=D0=BE?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D1=8F=D1=82=D1=8C=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/users/page.tsx | 55 +++++++++++++++++++++--------- docs/ux-ui-progress.md | 9 ++++- docs/ux-ui-stage-11a.md | 8 +++++ shared/api/users.ts | 7 ++-- tests/e2e/fixtures/users.ts | 2 ++ tests/e2e/users-context.spec.ts | 10 ++++++ tests/e2e/users-data.spec.ts | 3 +- tests/e2e/users-operations.spec.ts | 21 ++++++++++++ 8 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 tests/e2e/fixtures/users.ts create mode 100644 tests/e2e/users-operations.spec.ts diff --git a/app/(main)/users/page.tsx b/app/(main)/users/page.tsx index a0c21070..31e59da6 100644 --- a/app/(main)/users/page.tsx +++ b/app/(main)/users/page.tsx @@ -84,6 +84,13 @@ function UsersContent() { const loadController = useRef(null) const loadRevision = useRef(0) const [saving, setSaving] = useState(false) + const [deleting, setDeleting] = useState(false) + const [deleteError, setDeleteError] = useState(null) + const [operationNotice, setOperationNotice] = useState(null) + const mutationLock = useRef(false) + const mutationController = useRef(null) + const alive = useRef(true) + useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, []) const [sheetOpen, setSheetOpen] = useState(false) const [editUser, setEditUser] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) @@ -220,20 +227,32 @@ function UsersContent() { } const handleDelete = async () => { - if (!deleteTarget || !writeReady) return + if (!deleteTarget || !writeReady || mutationLock.current) return + const target = deleteTarget if (!isLive) { - setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id)) - setDeleteTarget(null) - toast.success("Пользователь удалён") + setUsers(prev => prev.filter(u => u.id !== target.id)); setDeleteTarget(null); setDeleteError(null) + toast.success("Пользователь удалён в демо.") return } + mutationLock.current = true + const controller = new AbortController() + mutationController.current = controller + setDeleting(true); setDeleteError(null); setOperationNotice(null) + const current = () => alive.current && !controller.signal.aborted try { - await deleteAppUser(backendUrl, deleteTarget.id) + await deleteAppUser(backendUrl, target.id, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) }) + if (!current()) return + setUsers(prev => prev.filter(u => u.id !== target.id)) setDeleteTarget(null) - await loadLive() - toast.success("Пользователь удалён") - } catch (err) { - toast.error(err instanceof Error ? err.message : "Не удалось удалить") + setOperationNotice("Удаление пользователя подтверждено API.") + toast.success("Пользователь удалён.") + const refreshed = await loadLive() + if (current() && !refreshed) setOperationNotice("Удаление пользователя подтверждено API. Обновление каталогов не удалось; повторите чтение.") + } catch (error) { + if (current()) setDeleteError(error instanceof Error ? error.message : "Удаление не подтверждено.") + } finally { + if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false } + if (alive.current) setDeleting(false) } } @@ -243,8 +262,8 @@ function UsersContent() { crumbs={[{ label: "Управление" }, { label: "Пользователи" }]} actions={ <> - {isLive && } - } + @@ -252,6 +271,7 @@ function UsersContent() { />
+ {operationNotice &&

{operationNotice}

} {!isLive &&

Демо: изменения сохраняются только в текущем экране.

} {!canRead &&

Для чтения пользователей требуется mm:users:read.

} {!canWrite &&

Для изменения пользователей требуется mm:users:write.

} @@ -295,9 +315,9 @@ function UsersContent() { serversCount={serversKnown ? serverOptions.length : null} allSectionsCount={ALL_SECTIONS.length} isLoading={loading && !usersKnown} - actionsDisabled={!writeReady || saving} + actionsDisabled={!writeReady || saving || deleting} onEdit={(u) => { setEditUser(u); setSheetOpen(true) }} - onDelete={setDeleteTarget} + onDelete={u => { setDeleteError(null); setDeleteTarget(u) }} /> }
@@ -316,7 +336,7 @@ function UsersContent() { /> {deleteTarget && ( - { if (!v) setDeleteTarget(null) }}> + { if (!v && !deleting) { setDeleteTarget(null); setDeleteError(null) } }}> @@ -328,9 +348,10 @@ function UsersContent() { - setDeleteTarget(null)}>Отмена - { void handleDelete() }}> - Удалить + {deleteError &&

{deleteError}

} + { setDeleteTarget(null); setDeleteError(null) }}>Отмена + { void handleDelete() }}> + {deleting ? "Удаление…" : "Удалить"}
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 0c24935c..44c8075d 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А.2; загрузка пользователей принята локально, операции далее | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.3; загрузка и удаление пользователей приняты локально, сохранение далее | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11А.3 — подтверждённое удаление пользователя (2026-10-01) + +- DELETE имеет single-flight lock, AbortSignal/30s срок и защиту позднего ответа после размонтирования. Ожидание блокирует повтор/отмену диалога; ошибка остаётся в диалоге с возможностью повторить, target не теряется. +- Удаление требует существующий backend контракт HTTP 204; успешный 200 с произвольным телом не принимается. Подтверждённая строка удаляется сразу, а отказ последующего refresh не называется отказом удаления: API результат и необходимость повторного чтения показаны отдельно. +- 17 data/operations E2E + 6 context E2E, TypeScript и профильный lint прошли. Проверены 403/503/неверный ответ, двойной click, Escape, refresh error, поздний success/error прежнего backend. Созданы общие HTTP-фикстуры пользователей. +- Далее 11А.4: безопасное сохранение профиля/черновика и частичных привязок, без повторного создания при подтверждённом первом шаге. Сохранение ещё не принято; interface catalog и визуальная приёмка далее. Реальный Portal/БД не проверен. + ## Блок 11А.2 — достоверная загрузка пользователей (2026-10-01) - Live не содержит INIT_USERS/демо серверов до API. Пользователи и серверы читаются независимо: быстрый список появляется до медленного каталога; отказ одной зависимости сохраняет другую и последние подтверждённые данные. Unknown KPI — —, подтверждённый пустой список — отдельное состояние. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 7281b892..d2293a06 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -49,3 +49,11 @@ - Права users read/write и server read проверены перед запросами/действиями. Без подтверждённых актуальных каталогов редактирование приостановлено; неизвестный общий серверный каталог не показывается числом 0. Icon действия получили имена; «Пригласить» заменено фактическим «Добавить пользователя». - 12 data E2E + 4 context E2E, профильный lint и TypeScript прошли. Production frontend прошёл в приёмочной сборке после подготовки нового контекста; дальнейшие операции ещё не приняты. Mock ветка подписана как локальное демо. - Далее 11А.3: lock удаления/сохранения, сохранение диалога/черновика при ошибке, поздние мутации и отдельное подтверждение API от refresh. Затем частичное сохранение привязок без дублей и interface catalog. Реальные Portal/БД ещё не проверены. + + +## Блок 11А.3 — подтверждённое удаление пользователя (2026-10-01) + +- DELETE имеет single-flight lock, AbortSignal/30s срок и защиту позднего ответа после размонтирования. Ожидание блокирует повтор/отмену диалога; ошибка остаётся в диалоге с возможностью повторить, target не теряется. +- Удаление требует существующий backend контракт HTTP 204; успешный 200 с произвольным телом не принимается. Подтверждённая строка удаляется сразу, а отказ последующего refresh не называется отказом удаления: API результат и необходимость повторного чтения показаны отдельно. +- 17 data/operations E2E + 6 context E2E, TypeScript и профильный lint прошли. Проверены 403/503/неверный ответ, двойной click, Escape, refresh error, поздний success/error прежнего backend. Созданы общие HTTP-фикстуры пользователей. +- Далее 11А.4: безопасное сохранение профиля/черновика и частичных привязок, без повторного создания при подтверждённом первом шаге. Сохранение ещё не принято; interface catalog и визуальная приёмка далее. Реальный Portal/БД не проверен. diff --git a/shared/api/users.ts b/shared/api/users.ts index 01dd8f8d..8284b72c 100644 --- a/shared/api/users.ts +++ b/shared/api/users.ts @@ -10,7 +10,7 @@ import { type UserBindingCreate, } from "@mmapp/contracts/users" import { z } from "zod" -import { requestJson } from "@/shared/api/http-client" +import { requestJson, requestBlob } from "@/shared/api/http-client" import type { AppUser, CatalogIface, InterfaceBinding } from "@/lib/users" const usersListPayload = z.object({ users: z.array(appUserReadSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторяющиеся пользователи в ответе API") }) @@ -71,8 +71,9 @@ export async function updateAppUser(baseUrl: string, id: string, data: AppUserUp return toFrontendUser(userPayload.parse(payload).user) } -export async function deleteAppUser(baseUrl: string, id: string): Promise { - await requestJson(baseUrl, `/api/users/${id}`, { method: "DELETE" }) +export async function deleteAppUser(baseUrl: string, id: string, init?: RequestInit): Promise { + const response = await requestBlob(baseUrl, "/api/users/" + encodeURIComponent(id), { ...init, method: "DELETE" }) + if (response.status !== 204) throw new Error("Удаление пользователя не подтверждено: ожидается HTTP 204.") } export async function createUserBinding( diff --git a/tests/e2e/fixtures/users.ts b/tests/e2e/fixtures/users.ts new file mode 100644 index 00000000..910a8770 --- /dev/null +++ b/tests/e2e/fixtures/users.ts @@ -0,0 +1,2 @@ +export const user={id:"u-test",name:"Настоящий пользователь",login:"test@example.test",email:"test@example.test",role:"viewer",active:true,avatar:"НП",lastSeen:null,sections:[],servers:[],bindings:[],createdAt:"2026-10-01T00:00:00Z",updatedAt:"2026-10-01T00:00:00Z"} +export const server={id:1,name:"Роутер",host:"192.0.2.1",port:443,useSsl:true,verifySsl:false,username:"admin",password:"",type:"exit-node",site:"",country:"RU",asn:"",comment:"",enabled:true,lanSubnet:"",wanUplinks:[],createdAt:"",updatedAt:"",status:null,latency:null,os:null,model:null,uptime:null,cpuLoad:null,freeMemory:null,totalMemory:null,identityName:null,sessions:0,polledAt:null} diff --git a/tests/e2e/users-context.spec.ts b/tests/e2e/users-context.spec.ts index 30630763..e354a02f 100644 --- a/tests/e2e/users-context.spec.ts +++ b/tests/e2e/users-context.spec.ts @@ -11,3 +11,13 @@ for(const change of ["backend","mode"])for(const fails of [false,true])test("П if(change==="backend")await expect(page.getByText(user.name,{exact:true})).toBeVisible();else await expect(page.getByRole("status").filter({hasText:"Демо: изменения"})).toBeVisible() await expect(page.getByText("СТАРЫЙ ПОЛЬЗОВАТЕЛЬ",{exact:true})).toHaveCount(0);await expect(page.getByText("СТАРАЯ ОШИБКА пользователей",{exact:true})).toHaveCount(0) }) + +for(const fails of [false,true])test("Пользователи: позднее удаление не меняет новый backend/"+fails,async({page})=>{ + let started=false,completed=false,release!:()=>void;const gate=new Promise(r=>release=r) + await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:[]})}) + const catalog={id:1,name:"Сервер",host:"192.0.2.1",port:443,useSsl:true,verifySsl:false,username:"admin",password:"",type:"exit-node",site:"",country:"RU",asn:"",comment:"",enabled:true,lanSubnet:"",wanUplinks:[],createdAt:"",updatedAt:"",status:null,latency:null,os:null,model:null,uptime:null,cpuLoad:null,freeMemory:null,totalMemory:null,identityName:null,sessions:0,polledAt:null} + await page.route("**/api/servers",r=>r.fulfill({headers,json:[catalog]}));await page.route("**/api/users/u-test",async r=>{started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРОЕ УДАЛЕНИЕ ОТКЛОНЕНО"}}:{headers,status:204,body:""})}catch{}finally{completed=true}}) + await page.goto("/users");await page.getByRole("button",{name:"Удалить пользователя "+user.name}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click();await expect.poll(()=>started).toBe(true) + await page.goto("/settings");const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter");await page.goto("/users");await expect(page.getByText(user.name,{exact:true})).toBeVisible();release();await expect.poll(()=>completed).toBe(true) + await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(page.getByText("СТАРОЕ УДАЛЕНИЕ ОТКЛОНЕНО",{exact:true})).toHaveCount(0);await expect(page.getByRole("status").filter({hasText:"Удаление пользователя подтверждено"})).toHaveCount(0) +}) diff --git a/tests/e2e/users-data.spec.ts b/tests/e2e/users-data.spec.ts index e1e8703d..5c262cbe 100644 --- a/tests/e2e/users-data.spec.ts +++ b/tests/e2e/users-data.spec.ts @@ -1,6 +1,5 @@ import { test, expect, type Page } from "@playwright/test" -const user={id:"u-test",name:"Настоящий пользователь",login:"test@example.test",email:"test@example.test",role:"viewer",active:true,avatar:"НП",lastSeen:null,sections:[],servers:[],bindings:[],createdAt:"2026-10-01T00:00:00Z",updatedAt:"2026-10-01T00:00:00Z"} -const server={id:1,name:"Роутер",host:"192.0.2.1",port:443,useSsl:true,verifySsl:false,username:"admin",password:"",type:"exit-node",site:"",country:"RU",asn:"",comment:"",enabled:true,lanSubnet:"",wanUplinks:[],createdAt:"",updatedAt:"",status:null,latency:null,os:null,model:null,uptime:null,cpuLoad:null,freeMemory:null,totalMemory:null,identityName:null,sessions:0,polledAt:null} +import {user,server} from "./fixtures/users" async function routes(page: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/users"?{users:[user]}:path==="/api/servers"?[server]:path==="/api/auth/config"?{required:false}:{interfaces:[]}})}) diff --git a/tests/e2e/users-operations.spec.ts b/tests/e2e/users-operations.spec.ts new file mode 100644 index 00000000..65dba4d5 --- /dev/null +++ b/tests/e2e/users-operations.spec.ts @@ -0,0 +1,21 @@ +import {test,expect} from "@playwright/test" +import {user,server} from "./fixtures/users" +test.beforeEach(async({page})=>{ + await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:p==="/api/servers"?[server]:{interfaces:[]}})}) +}) +for(const kind of ["403","503","malformed"] as const)test("Удаление пользователя: ошибка сохраняет диалог "+kind,async({page})=>{ + let fail=true,deleted=false;await page.route("**/api/users",r=>r.fulfill({json:{users:deleted?[]:[user]}})) + await page.route("**/api/users/u-test",r=>{if(fail)return r.fulfill(kind==="malformed"?{status:200,json:{success:true}}:{status:Number(kind),json:{error:"УДАЛЕНИЕ ОТКЛОНЕНО"}});deleted=true;return r.fulfill({status:204,body:""})}) + await page.goto("/users");await page.getByRole("button",{name:"Удалить пользователя "+user.name}).click();const dialog=page.getByRole("alertdialog");await dialog.getByRole("button",{name:"Удалить",exact:true}).click();await expect(dialog.getByRole("alert")).toBeVisible();await expect(dialog).toBeVisible();await expect(page.getByText(user.name,{exact:true})).toBeVisible() + fail=false;await dialog.getByRole("button",{name:"Удалить",exact:true}).click();await expect(dialog).toHaveCount(0);await expect(page.getByText("Нет пользователей",{exact:true})).toBeVisible();await expect(page.getByRole("status").filter({hasText:"Удаление пользователя подтверждено API."})).toBeVisible() +}) +test("Удаление пользователя: lock, Escape и один запрос",async({page})=>{ + let calls=0,deleted=false,release!:()=>void;const gate=new Promise(r=>release=r) + await page.route("**/api/users",r=>r.fulfill({json:{users:deleted?[]:[user]}}));await page.route("**/api/users/u-test",async r=>{calls++;await gate;deleted=true;await r.fulfill({status:204,body:""})}) + await page.goto("/users");await page.getByRole("button",{name:"Удалить пользователя "+user.name}).click();const dialog=page.getByRole("alertdialog");await dialog.getByRole("button",{name:"Удалить",exact:true}).evaluate(el=>{(el as HTMLButtonElement).click();(el as HTMLButtonElement).click()});await expect.poll(()=>calls).toBe(1);await expect(dialog.getByRole("button",{name:"Удаление…"})).toBeDisabled();await expect(dialog.getByRole("button",{name:"Отмена",exact:true})).toBeDisabled();await page.keyboard.press("Escape");await expect(dialog).toBeVisible();release();await expect(dialog).toHaveCount(0);expect(calls).toBe(1) +}) +test("Удаление пользователя: подтверждение отдельно от отказа refresh",async({page})=>{ + let deleted=false,fail=true;await page.route("**/api/users",r=>r.fulfill(deleted&&fail?{status:503,json:{error:"Список не обновлён"}}:{json:{users:deleted?[]:[user]}}));await page.route("**/api/users/u-test",r=>{deleted=true;return r.fulfill({status:204,body:""})}) + await page.goto("/users");await page.getByRole("button",{name:"Удалить пользователя "+user.name}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click();await expect(page.getByRole("status").filter({hasText:"Удаление пользователя подтверждено API. Обновление каталогов не удалось"})).toBeVisible();await expect(page.getByRole("alert",{name:"Пользователи",exact:true})).toContainText("Список не обновлён");await expect(page.getByText(user.name,{exact:true})).toHaveCount(0) + fail=false;await page.getByRole("button",{name:"Обновить пользователей",exact:true}).click();await expect(page.getByRole("button",{name:"Добавить пользователя",exact:true})).toBeEnabled() +})