diff --git a/components/users/user-sheet.tsx b/components/users/user-sheet.tsx index ee071091..de0fba0a 100644 --- a/components/users/user-sheet.tsx +++ b/components/users/user-sheet.tsx @@ -31,7 +31,8 @@ import { import { Flag } from "@/components/flag" import { StatusDot } from "@/components/status-dot" import { cn } from "@/lib/utils" -import { listInterfaceCatalog } from "@/shared/api/users" +import { can } from "@/lib/auth" +import { listInterfaceCatalog, userApiError } from "@/shared/api/users" import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail" import { bindingDiffKey, @@ -157,32 +158,33 @@ function UserSheet({ const [form, setForm] = useState(() => (user ? toForm(user) : emptyForm(servers))) const [errors, setErrors] = useState>>({}) const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "") - const [catalog, setCatalog] = useState([]) const [selectedPicks, setSelectedPicks] = useState([]) const [expandedWg, setExpandedWg] = useState(null) const [newComment, setNewComment] = useState("") + const catalogSrv = servers.find(s => s.id === catalogServerId) + const catalogKey = JSON.stringify([catalogServerId, catalogSrv?.host]) + const [catalogState, setCatalogState] = useState<{ key: string; serverId: string; rows: CatalogIface[]; loading: boolean; error?: string; readAt?: number } | null>(null) + const [catalogRevision, setCatalogRevision] = useState(0) + const demoCatalog = useMemo(() => { + if (isLive || !catalogSrv) return [] + const occupancy = users.map(u => u.id === user?.id ? { ...u, bindings: form.bindings } : u) + const withDraft = user ? occupancy : [...occupancy, { id: "__draft__", name: "", login: "", email: "", role: "viewer" as const, last: "", avatar: "", active: true, sections: [], servers: [], bindings: form.bindings }] + return catalogForServer(catalogServerId, withDraft) + }, [isLive, catalogSrv, catalogServerId, users, user, form.bindings]) + const currentCatalog = catalogState?.key === catalogKey ? catalogState : null + const catalog = isLive ? currentCatalog?.rows ?? [] : demoCatalog + const catalogReady = !!catalogSrv && (!isLive || !!currentCatalog?.readAt && !currentCatalog.loading && !currentCatalog.error) useEffect(() => { - if (!open || !catalogServerId) { - setCatalog([]) - return - } - if (!isLive) { - const occupancy = users.map((u) => - u.id === user?.id ? { ...u, bindings: form.bindings } : u, - ) - const withDraft = user - ? occupancy - : [...occupancy, { id: "__draft__", name: "", login: "", email: "", role: "viewer" as const, last: "", avatar: "", active: true, sections: [], servers: [], bindings: form.bindings }] - setCatalog(catalogForServer(catalogServerId, withDraft)) - return - } + if (!open || !isLive || !catalogSrv || !can("mm:users:read")) return let cancelled = false - void listInterfaceCatalog(backendUrl, catalogServerId) - .then((ifaces) => { if (!cancelled) setCatalog(ifaces) }) - .catch(() => { if (!cancelled) setCatalog([]) }) - return () => { cancelled = true } - }, [open, catalogServerId, isLive, backendUrl, users, user, form.bindings]) + const controller = new AbortController() + queueMicrotask(() => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, rows: prev?.key === catalogKey ? prev.rows : [], readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: true })) }) + void listInterfaceCatalog(backendUrl, catalogServerId, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) }) + .then(rows => { if (!cancelled) setCatalogState({ key: catalogKey, serverId: catalogServerId, rows, loading: false, readAt: Date.now() }) }) + .catch(error => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, rows: prev?.key === catalogKey ? prev.rows : [], readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: false, error: userApiError(error) })) }) + return () => { cancelled = true; controller.abort() } + }, [open, isLive, catalogSrv, catalogServerId, catalogKey, backendUrl, catalogRevision]) const setField = (k: K, v: AppUserForm[K]) => { setForm((f) => ({ ...f, [k]: v })) @@ -234,13 +236,22 @@ function UserSheet({ } const isAdmin = form.role === "admin" - const catalogSrv = servers.find((s) => s.id === catalogServerId) + + const validSelectedPicks = catalogReady ? selectedPicks.filter(pick => { + const iface = catalog.find(item => item.name === pick.interfaceName && item.type === pick.type) + if (!iface || iface.peersError || iface.boundUserId && iface.boundUserId !== user?.id) return false + if (iface.type === "wg" || iface.type === "ipsec") { + const peer = iface.peers?.find(item => item.publicKey === pick.peerPublicKey) + return !!peer && (!peer.boundUserId || peer.boundUserId === user?.id) + } + return !pick.peerPublicKey + }) : [] const addSelectedBindings = () => { - if (!catalogSrv || selectedPicks.length === 0) return + if (!catalogReady || !catalogSrv || validSelectedPicks.length === 0 || saving) return const existing = new Set(form.bindings.map(bindingDiffKey)) const next: InterfaceBinding[] = [...form.bindings] - for (const pick of selectedPicks) { + for (const pick of validSelectedPicks) { const key = bindingDiffKey({ serverId: catalogSrv.id, interfaceName: pick.interfaceName, @@ -452,30 +463,36 @@ function UserSheet({

- Ethernet и GRE — целиком. WireGuard — только пир (public-key). + Ethernet и GRE — целиком. WireGuard — пир (public-key), IPsec — клиент (CN).

- - - {catalog.length === 0 && ( + {!catalogSrv &&

Выберите сервер каталога интерфейсов.

} + {isLive && currentCatalog?.loading &&

Чтение каталога интерфейсов…

} + {isLive && currentCatalog?.readAt &&

Каталог получен: {new Date(currentCatalog.readAt).toLocaleTimeString("ru-RU")}

} + {isLive && currentCatalog?.error &&

{currentCatalog.error} Прежние данные доступны для просмотра; выбор приостановлен.

} + + + {catalogReady && catalog.length === 0 && (

Нет интерфейсов в каталоге

)}
{catalog.map((iface) => { const tile = IFACE_TILE[iface.type] const Icon = tile.icon - if (iface.type === "wg") { + if (iface.type === "wg" || iface.type === "ipsec") { const open = expandedWg === iface.name const legacyTaken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id) const legacyMine = alreadyBoundHere.has(`${iface.name}\0`) @@ -495,7 +512,7 @@ function UserSheet({ {iface.name} - WireGuard + {IFACE_TYPE_LABEL[iface.type]} @@ -508,21 +525,21 @@ function UserSheet({ {open ? (
{iface.peersError ? ( -

Не удалось загрузить пиры

+

Не удалось загрузить {iface.type === "ipsec" ? "клиентов IPsec" : "пиры WireGuard"}: {iface.peersError}

) : null} {(iface.peers ?? []).length === 0 && !iface.peersError ? ( -

Нет пиров на интерфейсе

+

{iface.type === "ipsec" ? "Нет клиентов IPsec" : "Нет пиров на интерфейсе"}

) : null} {(iface.peers ?? []).map((peer) => { const pick: CatalogPick = { interfaceName: iface.name, peerPublicKey: peer.publicKey, peerName: peer.name, - type: "wg", + type: iface.type, } const taken = Boolean(peer.boundUserId && peer.boundUserId !== user?.id) const mine = alreadyBoundHere.has(`${iface.name}\0${peer.publicKey}`) - const disabled = taken || mine || legacyTaken + const disabled = !catalogReady || !!iface.peersError || taken || mine || legacyTaken const selected = selectedPickKeys.has(pickKey(pick)) return ( {peer.name || peer.publicKey} {peer.latestHandshake ? ( - handshake + {iface.type === "ipsec" ? "время активности" : "время handshake"} ) : null} @@ -576,7 +593,7 @@ function UserSheet({ const pick: CatalogPick = { interfaceName: iface.name, peerPublicKey: "", type: iface.type } const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id) const mine = alreadyBoundHere.has(`${iface.name}\0`) - const disabled = taken || mine + const disabled = !catalogReady || taken || mine const selected = selectedPickKeys.has(pickKey(pick)) return ( setNewComment(e.target.value)} /> -
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 05a5cc65..ad0d2393 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А.4; загрузка, удаление и сохранение пользователей приняты локально, каталог/UX далее | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.5; frontend пользователей/каталога принят по API фикстурам; backend каталог/UX далее | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11А.5 — каталог интерфейсов формы (2026-10-01) + +- Каталог привязан к serverId/host; ожидание, error и подтверждённое empty различаются. Поздний ответ другого устройства отменяется. При повторе сохраняются прошлые строки/время получения, выбор блокируется до успешного чтения; черновик формы не сбрасывается. +- API схемы отклоняют дубли интерфейсов/пиров, отсутствующий peer snapshot и пустые ключи. Ошибка peers показана явно, не выдаётся за пустой список; выбор неподтверждённого пира блокируется. Перед привязкой выбранные объекты сверяются с текущим каталогом. +- IPsec теперь выбирает конкретного клиента/CN как требует backend; раньше весь ipsec-vpn отправлялся с пустым ключом. WireGuard остаётся выбором public-key. Время handshake/активности не называется текущим подключением. +- Frame scroll имеет высоту родителя и min-h-0 flex цепочку. Демо каталог вычисляется при рендере; последняя исходная UserSheet set-state-in-effect ошибка устранена без suppressions. +- 25 catalog/operations E2E, TypeScript и профильный lint прошли. Проверены API/malformed/дубли/неполные peer данные, empty/loading, поздний success/error другого сервера, WG failure/retry, IPsec CN payload и прежние операции. +- Далее 11А.6: backend достоверность interface catalog (сейчас snapshot/история без метаданных, неизвестные флаги и скрытый IPsec failure); затем UI/доступность и локальная приёмка пользователей. Реальные RouterOS/DB/Portal не проверены. + ## Блок 11А.4 — многошаговое сохранение профиля и привязок (2026-10-01) - Общий mutation lock, отмена/30s срок каждого запроса, защита прежнего источника; Sheet не сбрасывает черновик при изменении каталога/подтверждённом промежуточном шаге. Во время отправки поля/кнопки и закрытие блокируются. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 12df9b2a..d529c05d 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -67,3 +67,13 @@ - Ответы проверяют userId/login, отправленные поля/права и контекст привязки. Wrong role/sections/servers не подтверждают сохранение. IDs в URL кодируются. Техническая многострочная ошибка схемы заменена понятной краткой ошибкой — иначе footer выходил за экран; обнаружено E2E, исправлено и повторено без ослабления теста. - 5 fault-injection unit, 27 различных data/operations E2E и 8 context E2E прошли; TypeScript, профильный lint и production frontend прошли. В UserSheet остаётся одна исходная catalog set-state-in-effect ошибка; она входит в следующий блок, suppressions нет. - Далее 11А.5: строгий interface catalog, loading/error/empty, точный сервер и поздние ответы/peer сбои; затем форма/таблица, клавиатура и ПК/телефон. Реальная БД/Portal не проверены. + + +## Блок 11А.5 — каталог интерфейсов формы (2026-10-01) + +- Каталог привязан к serverId/host; ожидание, error и подтверждённое empty различаются. Поздний ответ другого устройства отменяется. При повторе сохраняются прошлые строки/время получения, выбор блокируется до успешного чтения; черновик формы не сбрасывается. +- API схемы отклоняют дубли интерфейсов/пиров, отсутствующий peer snapshot и пустые ключи. Ошибка peers показана явно, не выдаётся за пустой список; выбор неподтверждённого пира блокируется. Перед привязкой выбранные объекты сверяются с текущим каталогом. +- IPsec теперь выбирает конкретного клиента/CN как требует backend; раньше весь ipsec-vpn отправлялся с пустым ключом. WireGuard остаётся выбором public-key. Время handshake/активности не называется текущим подключением. +- Frame scroll имеет высоту родителя и min-h-0 flex цепочку. Демо каталог вычисляется при рендере; последняя исходная UserSheet set-state-in-effect ошибка устранена без suppressions. +- 25 catalog/operations E2E, TypeScript и профильный lint прошли. Проверены API/malformed/дубли/неполные peer данные, empty/loading, поздний success/error другого сервера, WG failure/retry, IPsec CN payload и прежние операции. +- Далее 11А.6: backend достоверность interface catalog (сейчас snapshot/история без метаданных, неизвестные флаги и скрытый IPsec failure); затем UI/доступность и локальная приёмка пользователей. Реальные RouterOS/DB/Portal не проверены. diff --git a/shared/api/users.ts b/shared/api/users.ts index 0fa4d6d4..6c736621 100644 --- a/shared/api/users.ts +++ b/shared/api/users.ts @@ -18,7 +18,8 @@ const checkedUserSchema = appUserReadSchema.refine(user => user.bindings.every(b const usersListPayload = z.object({ users: z.array(checkedUserSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторяющиеся пользователи в ответе API") }) const userPayload = z.object({ user: checkedUserSchema }) const bindingPayload = z.object({ binding: userBindingSchema }) -const catalogPayload = z.object({ interfaces: z.array(catalogInterfaceSchema) }) +const checkedCatalogInterface = catalogInterfaceSchema.refine(iface => iface.name.trim().length > 0 && (!(iface.type === "wg" || iface.type === "ipsec") || iface.peers !== undefined || !!iface.peersError) && (!iface.peers || iface.peers.every(peer => peer.publicKey.trim().length > 0) && new Set(iface.peers.map(peer => peer.publicKey)).size === iface.peers.length), "Неполный или неоднозначный каталог интерфейсов") +const catalogPayload = z.object({ interfaces: z.array(checkedCatalogInterface).refine(rows => new Set(rows.map(row => row.name)).size === rows.length, "Повторяющиеся интерфейсы") }) export function toFrontendBinding(b: UserBinding): InterfaceBinding { return { @@ -132,10 +133,12 @@ export async function deleteUserBinding(baseUrl: string, userId: string, binding export async function listInterfaceCatalog( baseUrl: string, serverId: string, + init?: RequestInit, ): Promise { const payload = await requestJson( baseUrl, `/api/users/interface-catalog?serverId=${encodeURIComponent(serverId)}`, + init, ) return catalogPayload.parse(payload).interfaces as CatalogInterface[] } diff --git a/tests/e2e/users-catalog.spec.ts b/tests/e2e/users-catalog.spec.ts new file mode 100644 index 00000000..1cb0904a --- /dev/null +++ b/tests/e2e/users-catalog.spec.ts @@ -0,0 +1,34 @@ +import {test,expect,type Page} from "@playwright/test" +import {user,server} from "./fixtures/users" +const iface={name:"ether1",type:"ether",running:true,disabled:false,boundUserId:null,boundUserLogin:null} +const peer={publicKey:"client-cn",name:"Клиент VPN",comment:"",allowedIps:[],boundUserId:null,boundUserLogin:null} +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:[]}})}) +}) +async function open(page:Page){await page.goto("/users");await page.getByRole("button",{name:"Добавить пользователя",exact:true}).click();await page.getByPlaceholder("Иван Иванов").fill("Каталог тест");await page.getByPlaceholder("i.ivanov@company.io").fill("catalog@example.test");for(let i=0;i<3;i++)await page.getByRole("dialog").getByRole("button",{name:"Далее",exact:true}).click()} +for(const kind of ["API","malformed","duplicate","missing-peers","duplicate-peers"])test("Каталог пользователя: отказ и повтор "+kind,async({page})=>{ + let fail=true;await page.route("**/api/users/interface-catalog?*",r=>r.fulfill(fail?(kind==="API"?{status:503,json:{error:"КАТАЛОГ НЕДОСТУПЕН"}}:{json:{interfaces:kind==="malformed"?[{}]:kind==="duplicate"?[iface,iface]:kind==="missing-peers"?[{...iface,type:"wg"}]:[{...iface,type:"wg",peers:[peer,peer]}]}}):{json:{interfaces:[iface]}})) + await open(page);const dialog=page.getByRole("dialog");await expect(dialog.getByRole("alert",{name:"Каталог интерфейсов"})).toBeVisible();await expect(dialog.getByText("Нет интерфейсов в каталоге",{exact:true})).toHaveCount(0);await expect(dialog.getByRole("button",{name:"Привязать",exact:true})).toBeDisabled() + fail=false;await dialog.getByRole("button",{name:"Повторить каталог интерфейсов"}).click();await dialog.getByRole("button",{name:/ether1.*Ethernet/}).click();await expect(dialog.getByRole("button",{name:"Привязать",exact:true})).toBeEnabled() +}) +test("Каталог пользователя: подтверждённое empty отличается от ожидания",async({page})=>{ + let release!:()=>void;const gate=new Promise(r=>release=r);await page.route("**/api/users/interface-catalog?*",async r=>{await gate;await r.fulfill({json:{interfaces:[]}})}) + await open(page);await expect(page.getByRole("status").filter({hasText:"Чтение каталога интерфейсов"})).toBeVisible();await expect(page.getByText("Нет интерфейсов в каталоге",{exact:true})).toHaveCount(0);release();await expect(page.getByText("Нет интерфейсов в каталоге",{exact:true})).toBeVisible() +}) +for(const fails of [false,true])test("Каталог пользователя: поздний ответ другого устройства "+fails,async({page})=>{ + let started=false,completed=false,release!:()=>void;const gate=new Promise(r=>release=r);await page.route("**/api/servers",r=>r.fulfill({json:[server,{...server,id:2,name:"Второй",host:"192.0.2.2"}]})) + await page.route("**/api/users/interface-catalog?*",async r=>{if(new URL(r.request().url()).searchParams.get("serverId")==="2")return r.fulfill({json:{interfaces:[{...iface,name:"ether2"}]}});started=true;await gate;try{await r.fulfill(fails?{status:503,json:{error:"СТАРЫЙ КАТАЛОГ"}}:{json:{interfaces:[iface]}})}catch{}finally{completed=true}}) + await open(page);await expect.poll(()=>started).toBe(true);await page.getByRole("combobox",{name:"Сервер каталога интерфейсов"}).selectOption("2");await expect(page.getByRole("button",{name:/ether2.*Ethernet/})).toBeEnabled();release();await expect.poll(()=>completed).toBe(true);await expect(page.getByRole("button",{name:/ether1.*Ethernet/})).toHaveCount(0);await expect(page.getByText("СТАРЫЙ КАТАЛОГ",{exact:true})).toHaveCount(0) +}) +test("Каталог пользователя: ошибка пиров блокирует выбор, повтор восстанавливает",async({page})=>{ + let fail=true;await page.route("**/api/users/interface-catalog?*",r=>r.fulfill({json:{interfaces:[{...iface,name:"wg1",type:"wg",peers:[peer],...(fail?{peersError:"WG ОТКАЗ"}:{})}]}})) + await open(page);await page.getByRole("button",{name:/wg1.*WireGuard/}).click();await expect(page.getByRole("alert").filter({hasText:"WG ОТКАЗ"})).toBeVisible();await expect(page.getByRole("button",{name:"Клиент VPN",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Привязать",exact:true})).toBeDisabled() + fail=false;await page.getByRole("button",{name:"Повторить каталог интерфейсов"}).click();await page.getByRole("button",{name:"Клиент VPN",exact:true}).click();await expect(page.getByRole("button",{name:"Привязать",exact:true})).toBeEnabled() +}) +test("Каталог пользователя: IPsec привязывает клиента по CN",async({page})=>{ + let data:Record|null=null + await page.route("**/api/users/interface-catalog?*",r=>r.fulfill({json:{interfaces:[{...iface,name:"ipsec-vpn",type:"ipsec",peers:[peer]}]}})) + await page.route("**/api/users",r=>r.fulfill({json:r.request().method()==="POST"?{user:{...user,...r.request().postDataJSON(),id:"created"}}:{users:[user]}})) + await page.route("**/api/users/created/bindings",r=>{data=r.request().postDataJSON();return r.fulfill({status:201,json:{binding:{...data,id:"binding",userId:"created",serverName:"Роутер",serverSite:"",serverCountry:"RU",comment:"",createdAt:"",updatedAt:""}}})}) + await open(page);await page.getByRole("button",{name:/ipsec-vpn.*IPsec/}).click();await page.getByRole("button",{name:"Клиент VPN",exact:true}).click();await page.getByRole("button",{name:"Привязать",exact:true}).click();await page.getByRole("dialog").getByRole("button",{name:"Создать",exact:true}).click();await expect(page.getByRole("dialog")).toHaveCount(0);expect(data).toMatchObject({serverId:1,interfaceName:"ipsec-vpn",interfaceType:"ipsec",peerPublicKey:"client-cn"}) +})