fix(ui): подтверждать удаление пользователя и сохранять ошибку
This commit is contained in:
1 parent
2d5fb3598f
commit
02ca48ef9c
8 files changed
+92
-23
No files matched your search
+38
-17
@@ -84,6 +84,13 @@ function UsersContent() {
|
||||
const loadController = useRef<AbortController | null>(null)
|
||||
const loadRevision = useRef(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||
const mutationLock = useRef(false)
|
||||
const mutationController = useRef<AbortController | null>(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<AppUser | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(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 && <Button type="button" size="sm" variant="outline" disabled={!canRead || loading || saving || sheetOpen} onClick={() => void loadLive()}>Обновить пользователей</Button>}
|
||||
<Button type="button" size="sm" disabled={!writeReady} onClick={() => { setEditUser(null); setSheetOpen(true) }}>
|
||||
{isLive && <Button type="button" size="sm" variant="outline" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить пользователей</Button>}
|
||||
<Button type="button" size="sm" disabled={!writeReady || saving || deleting} onClick={() => { setEditUser(null); setSheetOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить пользователя
|
||||
</Button>
|
||||
</>
|
||||
@@ -252,6 +271,7 @@ function UsersContent() {
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
|
||||
{operationNotice && <p role="status" className="text-sm text-muted-foreground">{operationNotice}</p>}
|
||||
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: изменения сохраняются только в текущем экране.</p>}
|
||||
{!canRead && <p role="status">Для чтения пользователей требуется mm:users:read.</p>}
|
||||
{!canWrite && <p role="status">Для изменения пользователей требуется mm:users:write.</p>}
|
||||
@@ -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) }}
|
||||
/>
|
||||
</DataPageCard>}
|
||||
</div>
|
||||
@@ -316,7 +336,7 @@ function UsersContent() {
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null) }}>
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v && !deleting) { setDeleteTarget(null); setDeleteError(null) } }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
@@ -328,9 +348,10 @@ function UsersContent() {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
|
||||
Удалить
|
||||
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||
<AlertDialogCancel disabled={deleting} onClick={() => { setDeleteTarget(null); setDeleteError(null) }}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction type="button" disabled={deleting || !writeReady} aria-busy={deleting} variant="destructive" onClick={() => { void handleDelete() }}>
|
||||
{deleting ? "Удаление…" : "Удалить"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -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 — —, подтверждённый пустой список — отдельное состояние.
|
||||
|
||||
@@ -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/БД не проверен.
|
||||
+4
-3
@@ -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<void> {
|
||||
await requestJson<void>(baseUrl, `/api/users/${id}`, { method: "DELETE" })
|
||||
export async function deleteAppUser(baseUrl: string, id: string, init?: RequestInit): Promise<void> {
|
||||
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(
|
||||
|
||||
@@ -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}
|
||||
@@ -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<void>(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)
|
||||
})
|
||||
@@ -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:[]}})})
|
||||
|
||||
@@ -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<void>(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()
|
||||
})
|
||||
Reference in new issue
Block a user