diff --git a/app/(main)/users/page.tsx b/app/(main)/users/page.tsx index fd3063ee..a0c21070 100644 --- a/app/(main)/users/page.tsx +++ b/app/(main)/users/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { PageHeader } from "@/components/page-header" import { DataPageCard } from "@/components/data-page-card" import { UsersDataGrid } from "@/components/data-grids/users-data-grid" @@ -29,7 +29,9 @@ import { type AppUserForm, type UserServerOption, } from "@/lib/users" -import { listServers } from "@/shared/api/servers" +import { serverListSchema } from "@mmapp/contracts/servers" +import { requestJson } from "@/shared/api/http-client" +import { can } from "@/lib/auth" import { createAppUser, createUserBinding, @@ -60,49 +62,78 @@ function toServerOptionsFromMock(): UserServerOption[] { } export default function UsersPage() { + const { mode, backendUrl, prefsHydrated } = useDataSource() + if (!prefsHydrated) return

Загрузка настроек пользователей…

+ return +} + +function UsersContent() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" + const canRead = can("mm:users:read") + const canWrite = can("mm:users:write") + const canReadServers = can("mm:servers:read") - const [users, setUsers] = useState(INIT_USERS) - const [serverOptions, setServerOptions] = useState(toServerOptionsFromMock) - const [loading, setLoading] = useState(false) + const [users, setUsers] = useState(() => isLive ? [] : INIT_USERS) + const [serverOptions, setServerOptions] = useState(() => isLive ? [] : toServerOptionsFromMock()) + const [loading, setLoading] = useState(isLive && canRead) + const [usersReadAt, setUsersReadAt] = useState(null) + const [serversReadAt, setServersReadAt] = useState(null) + const [usersError, setUsersError] = useState(null) + const [serversError, setServersError] = useState(null) + const loadController = useRef(null) + const loadRevision = useRef(0) const [saving, setSaving] = useState(false) const [sheetOpen, setSheetOpen] = useState(false) const [editUser, setEditUser] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) const loadLive = useCallback(async () => { + if (!isLive || !canRead) return false + loadController.current?.abort() + const controller = new AbortController() + loadController.current = controller + const revision = ++loadRevision.current + const current = () => !controller.signal.aborted && revision === loadRevision.current setLoading(true) - try { - const [list, srvs] = await Promise.all([ - listAppUsers(backendUrl), - listServers(backendUrl), - ]) - setUsers(list) - setServerOptions(srvs.map((s) => ({ - id: String(s.id), - name: s.name || s.host, - host: s.host, - site: s.site, - country: s.country, - status: s.status, - }))) - } catch (err) { - toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей") - } finally { - setLoading(false) - } - }, [backendUrl]) + const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) + const [list, srvs] = await Promise.allSettled([ + listAppUsers(backendUrl, { signal }).then(rows => { + if (current()) { setUsers(rows); setUsersReadAt(Date.now()); setUsersError(null) } + return rows + }).catch(error => { + if (current()) setUsersError(error instanceof Error ? error.message : "Не удалось прочитать пользователей.") + throw error + }), + canReadServers ? requestJson(backendUrl, "/api/servers", { signal }).then(raw => { + const rows = serverListSchema.parse(raw) + if (new Set(rows.map(row => row.id)).size !== rows.length || rows.some(row => row.id <= 0)) throw new Error("Некорректный каталог серверов.") + if (current()) { + setServerOptions(rows.map(s => ({ + id: String(s.id), name: s.name || s.host, host: s.host, site: s.site, country: s.country, status: s.status, + }))) + setServersReadAt(Date.now()); setServersError(null) + } + return rows + }).catch(error => { + if (current()) setServersError(error instanceof Error ? error.message : "Не удалось прочитать серверы.") + throw error + }) : Promise.resolve(null), + ]) + if (!current()) return false + setLoading(false) + return list.status === "fulfilled" && srvs.status === "fulfilled" + }, [isLive, canRead, canReadServers, backendUrl]) useEffect(() => { - if (!isLive) { - setUsers(INIT_USERS) - setServerOptions(toServerOptionsFromMock()) - return - } - void loadLive() - }, [isLive, loadLive]) + let cancelled = false + if (isLive && canRead) queueMicrotask(() => { if (!cancelled) void loadLive() }) + return () => { cancelled = true; loadController.current?.abort() } + }, [isLive, canRead, loadLive]) + const usersKnown = !isLive || usersReadAt !== null + const serversKnown = !isLive || serversReadAt !== null + const writeReady = canWrite && (!isLive || (usersKnown && serversKnown && !loading && !usersError && !serversError)) const boundCount = users.reduce((n, u) => n + u.bindings.length, 0) const activeCount = users.filter((u) => u.active).length @@ -129,6 +160,7 @@ export default function UsersPage() { } const handleSave = async (form: AppUserForm) => { + if (!writeReady || saving) return if (!isLive) { if (!editUser) { const id = `u${Date.now()}` @@ -188,7 +220,7 @@ export default function UsersPage() { } const handleDelete = async () => { - if (!deleteTarget) return + if (!deleteTarget || !writeReady) return if (!isLive) { setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id)) setDeleteTarget(null) @@ -210,13 +242,24 @@ export default function UsersPage() { { setEditUser(null); setSheetOpen(true) }}> - Пригласить + <> + {isLive && } + + } />
+ {!isLive &&

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

} + {!canRead &&

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

} + {!canWrite &&

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

} + {isLive && !canReadServers &&

Каталог серверов недоступен: требуется mm:servers:read. Изменения приостановлены.

} + {usersError &&

{usersKnown ? "Не удалось обновить пользователей. Последний список сохранён." : "Пользователи пока неизвестны."}

{usersError}

} + {serversError &&

{serversKnown ? "Не удалось обновить серверы. Последний каталог сохранён." : "Каталог серверов пока неизвестен."} Изменения приостановлены.

{serversError}

} + {isLive && usersReadAt !== null &&

Пользователи получены: {new Date(usersReadAt).toLocaleTimeString("ru-RU")}{loading ? " · обновляются" : ""}

} + {isLive && loading && !usersKnown &&

Загрузка пользователей…

} {/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */} , iconClassName: "text-muted-foreground", }, { id: "active", label: "Активные", - value: activeCount, + value: usersKnown ? activeCount : "—", icon: , iconClassName: "text-success", }, { id: "ifaces", - label: "Привязанные ifaces", - value: boundCount, + label: "Привязанные интерфейсы", + value: usersKnown ? boundCount : "—", icon: , iconClassName: "text-info", }, @@ -246,16 +289,17 @@ export default function UsersPage() { /> {/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */} - + {canRead && (usersKnown || loading) && { setEditUser(u); setSheetOpen(true) }} onDelete={setDeleteTarget} /> - + }
void onDelete: (user: AppUser) => void } @@ -56,6 +57,7 @@ function UsersDataGrid({ serversCount, allSectionsCount = ALL_SECTIONS.length, isLoading, + actionsDisabled, onEdit, onDelete, }: UsersDataGridProps) { @@ -182,6 +184,9 @@ function UsersDataGrid({ variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground" onClick={() => onEdit(u)} + type="button" + disabled={actionsDisabled} + aria-label={"Редактировать пользователя " + u.name} title="Редактировать" > @@ -191,6 +196,9 @@ function UsersDataGrid({ variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-destructive" onClick={() => onDelete(u)} + type="button" + disabled={actionsDisabled} + aria-label={"Удалить пользователя " + u.name} title="Удалить" > @@ -205,7 +213,7 @@ function UsersDataGrid({ }, }, ], - [allSectionsCount, onDelete, onEdit, serversCount], + [allSectionsCount, actionsDisabled, onDelete, onEdit, serversCount], ) const table = useTable({ diff --git a/components/data-grids/users-expanded-detail.tsx b/components/data-grids/users-expanded-detail.tsx index 7e0ab196..ecef71cd 100644 --- a/components/data-grids/users-expanded-detail.tsx +++ b/components/data-grids/users-expanded-detail.tsx @@ -32,7 +32,7 @@ const TYPE_ICON: Record Серверы:{" "} - {access.serversGranted}/{access.serversTotal} + {serversCount === null ? "каталог неизвестен" : access.serversGranted + "/" + access.serversTotal} {access.writeKind === "full" ? ( diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 7a9b2f35..0c24935c 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; карта API и сценариев сохранена | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.2; загрузка пользователей принята локально, операции далее | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11А.2 — достоверная загрузка пользователей (2026-10-01) + +- Live не содержит INIT_USERS/демо серверов до API. Пользователи и серверы читаются независимо: быстрый список появляется до медленного каталога; отказ одной зависимости сохраняет другую и последние подтверждённые данные. Unknown KPI — —, подтверждённый пустой список — отдельное состояние. +- Получение пользователей имеет время браузерного чтения и ручной повтор. Смена backend/mode размонтирует прежний контекст, читатели отменяются и поздние ответы не меняют новый экран. Схемы обязательны; дубли ID отклоняются. +- Права 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А.1 — подготовка администрирования (2026-10-01) - Сохранён docs/ux-ui-stage-11a.md: референсы, схема, порядок пользователей/контейнеров/сертификатов/backup, API и точки завершения. Чтение и многошаговые операции пользователей разделены на проверяемые блоки. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 30218323..7281b892 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -40,3 +40,12 @@ - Серверный каталог — зависимость формы прав/привязок; отсутствие доступа или отказ не должен обнулять сохранённые разрешения. - Backend/моки не подтверждают реальный Portal, PostgreSQL, RouterOS, выдачу сертификатов, восстановление backup или доступность S3. Реальную приёмку перечислять отдельно. - Оповещения, сбор данных, интеграции, релизы, настройки и расписание диагностики — 11Б. + + +## Блок 11А.2 — достоверная загрузка пользователей (2026-10-01) + +- Live не содержит INIT_USERS/демо серверов до API. Пользователи и серверы читаются независимо: быстрый список появляется до медленного каталога; отказ одной зависимости сохраняет другую и последние подтверждённые данные. Unknown KPI — —, подтверждённый пустой список — отдельное состояние. +- Получение пользователей имеет время браузерного чтения и ручной повтор. Смена backend/mode размонтирует прежний контекст, читатели отменяются и поздние ответы не меняют новый экран. Схемы обязательны; дубли ID отклоняются. +- Права 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/БД ещё не проверены. diff --git a/playwright.config.ts b/playwright.config.ts index 08758b56..cb78ccd2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], + testIgnore: ["**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], fullyParallel: false, workers: 1, retries: 0, diff --git a/playwright.users-context.config.ts b/playwright.users-context.config.ts new file mode 100644 index 00000000..b553c27b --- /dev/null +++ b/playwright.users-context.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "@playwright/test" +import base from "./playwright.config" +const server=base.webServer +if(!server||Array.isArray(server))throw new Error("Ожидается один локальный сервер") +export default defineConfig({ + ...base, use:{...base.use,baseURL:"http://127.0.0.1:3101"},testIgnore:[],testMatch:"users-context.spec.ts", + webServer:{...server,command:"node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101",url:"http://127.0.0.1:3101",env:{...server.env,NEXT_PUBLIC_BACKEND_URL:"local"}}, +}) diff --git a/shared/api/users.ts b/shared/api/users.ts index 86d64f0a..01dd8f8d 100644 --- a/shared/api/users.ts +++ b/shared/api/users.ts @@ -13,7 +13,7 @@ import { z } from "zod" import { requestJson } from "@/shared/api/http-client" import type { AppUser, CatalogIface, InterfaceBinding } from "@/lib/users" -const usersListPayload = z.object({ users: z.array(appUserReadSchema) }) +const usersListPayload = z.object({ users: z.array(appUserReadSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторяющиеся пользователи в ответе API") }) const userPayload = z.object({ user: appUserReadSchema }) const bindingPayload = z.object({ binding: userBindingSchema }) const catalogPayload = z.object({ interfaces: z.array(catalogInterfaceSchema) }) @@ -50,8 +50,8 @@ export function toFrontendUser(u: AppUserRead): AppUser { } } -export async function listAppUsers(baseUrl: string): Promise { - const payload = await requestJson(baseUrl, "/api/users") +export async function listAppUsers(baseUrl: string, init?: RequestInit): Promise { + const payload = await requestJson(baseUrl, "/api/users", init) return usersListPayload.parse(payload).users.map(toFrontendUser) } diff --git a/tests/e2e/users-context.spec.ts b/tests/e2e/users-context.spec.ts new file mode 100644 index 00000000..30630763 --- /dev/null +++ b/tests/e2e/users-context.spec.ts @@ -0,0 +1,13 @@ +import {test,expect} from "@playwright/test" +const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"} +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:"",updatedAt:""} +for(const change of ["backend","mode"])for(const fails of [false,true])test("Пользователи: позднее чтение/"+change+"/"+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]}:[]})}) + await page.route("**/api/users",async r=>{if(new URL(r.request().url()).port!=="8000")return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА пользователей"}}:{headers,json:{users:[{...user,name:"СТАРЫЙ ПОЛЬЗОВАТЕЛЬ"}]}})}catch{}finally{completed=true}}) + await page.goto("/users");await expect.poll(()=>started).toBe(true);await page.getByRole("link",{name:/^Настройки/}).click() + if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")}else await page.getByRole("button",{name:"Моковые",exact:true}).click() + await page.goto("/users");release();await expect.poll(()=>completed).toBe(true) + 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) +}) diff --git a/tests/e2e/users-data.spec.ts b/tests/e2e/users-data.spec.ts new file mode 100644 index 00000000..e1e8703d --- /dev/null +++ b/tests/e2e/users-data.spec.ts @@ -0,0 +1,43 @@ +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} +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:[]}})}) +} +test.beforeEach(async({page})=>routes(page)) +const add=(page:Page)=>page.getByRole("button",{name:"Добавить пользователя",exact:true}) +const refresh=(page:Page)=>page.getByRole("button",{name:"Обновить пользователей",exact:true}) +for(const kind of ["API","malformed","duplicate"] as const)test("Пользователи: первоначальный отказ "+kind,async({page})=>{ + let fail=true;await page.route("**/api/users",r=>r.fulfill(fail?(kind==="API"?{status:503,json:{error:"Отказ пользователей"}}:{json:{users:kind==="duplicate"?[user,user]:[{}]}}):{json:{users:[user]}})) + await page.goto("/users");await expect(page.getByRole("alert",{name:"Пользователи",exact:true})).toContainText("Пользователи пока неизвестны");await expect(page.getByText("Нет пользователей",{exact:true})).toHaveCount(0);await expect(add(page)).toBeDisabled();await expect(page.getByText("Алексей Коротaев",{exact:true})).toHaveCount(0) + fail=false;await refresh(page).click();await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(add(page)).toBeEnabled();await expect(page.getByRole("alert",{name:"Пользователи",exact:true})).toHaveCount(0) +}) +for(const kind of ["API","malformed","duplicate"] as const)test("Пользователи: независимый отказ серверов "+kind,async({page})=>{ + let fail=true;await page.route("**/api/servers",r=>r.fulfill(fail?(kind==="API"?{status:403,json:{error:"Серверы запрещены"}}:{json:kind==="duplicate"?[server,server]:[{}]}):{json:[server]})) + await page.goto("/users");await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(page.getByRole("alert",{name:"Серверы пользователей"})).toContainText("Каталог серверов пока неизвестен");await expect(add(page)).toBeDisabled() + await page.getByText(user.name,{exact:true}).click();await expect(page.getByText("каталог неизвестен",{exact:true})).toBeVisible() + fail=false;await refresh(page).click();await expect(add(page)).toBeEnabled();await expect(page.getByRole("alert",{name:"Серверы пользователей"})).toHaveCount(0) +}) +test("Пользователи: подтверждённый пустой список и нули",async({page})=>{ + await page.route("**/api/users",r=>r.fulfill({json:{users:[]}}));await page.goto("/users");await expect(page.getByText("Нет пользователей",{exact:true})).toBeVisible();await expect(add(page)).toBeEnabled();await expect(page.getByRole("alert",{name:"Пользователи",exact:true})).toHaveCount(0) +}) +for(const part of ["users","servers"])test("Пользователи: фоновый отказ сохраняет снимок "+part,async({page})=>{ + let fail=false;await page.route("**/api/"+part,r=>r.fulfill(fail?{status:503,json:{error:"ФОНОВЫЙ ОТКАЗ"}}:{json:part==="users"?{users:[user]}:[server]})) + await page.goto("/users");await expect(add(page)).toBeEnabled();fail=true;await refresh(page).click();await expect(page.getByText("ФОНОВЫЙ ОТКАЗ",{exact:true})).toBeVisible();await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(add(page)).toBeDisabled();await expect(page.getByRole("button",{name:"Редактировать пользователя "+user.name})).toBeDisabled() + fail=false;await refresh(page).click();await expect(add(page)).toBeEnabled();await expect(page.getByText("ФОНОВЫЙ ОТКАЗ",{exact:true})).toHaveCount(0) +}) +test("Пользователи: read права и отсутствие server read",async({page})=>{ + await page.addInitScript(()=>localStorage.setItem("mmapp_token","header."+btoa(JSON.stringify({sub:"test",apps:["mm"],permissions:["mm:users:read"],exp:Math.floor(Date.now()/1000)+3600}))+".signature")) + await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let servers=0;await page.route("**/api/servers",r=>{servers++;return r.fulfill({json:[server]})}) + await page.goto("/users");await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(add(page)).toBeDisabled();await expect(page.getByRole("button",{name:"Удалить пользователя "+user.name})).toBeDisabled();await expect(page.getByRole("status").filter({hasText:"mm:servers:read"})).toBeVisible();expect(servers).toBe(0) +}) +test("Пользователи: до ответа live нет демо-профилей",async({page})=>{ + let release!:()=>void;const gate=new Promise(r=>release=r);await page.route("**/api/users",async r=>{await gate;await r.fulfill({json:{users:[user]}}).catch(()=>{})}) + await page.goto("/users");await expect(page.getByRole("status").filter({hasText:"Загрузка пользователей…"})).toBeVisible();await expect(page.getByText(user.name,{exact:true})).toHaveCount(0);await expect(add(page)).toBeDisabled();await expect(page.getByText("Нет пользователей",{exact:true})).toHaveCount(0);release();await expect(add(page)).toBeEnabled() +}) + +test("Пользователи: медленный серверный каталог не задерживает список",async({page})=>{ + let release!:()=>void;const gate=new Promise(r=>release=r);await page.route("**/api/servers",async r=>{await gate;await r.fulfill({json:[server]}).catch(()=>{})}) + await page.goto("/users");await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(add(page)).toBeDisabled();release();await expect(add(page)).toBeEnabled() +})