fix(ui): показывать подтверждённые каталоги пользователей

This commit is contained in:
Denozordec committed 2026-10-01 09:34:51 +07:00
1 parent 0a332ce341
commit 2d5fb3598f
10 files changed
+184 -51

No files matched your search

+85 -41
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { UsersDataGrid } from "@/components/data-grids/users-data-grid" import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
@@ -29,7 +29,9 @@ import {
type AppUserForm, type AppUserForm,
type UserServerOption, type UserServerOption,
} from "@/lib/users" } 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 { import {
createAppUser, createAppUser,
createUserBinding, createUserBinding,
@@ -60,49 +62,78 @@ function toServerOptionsFromMock(): UserServerOption[] {
} }
export default function UsersPage() { export default function UsersPage() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return <p role="status">Загрузка настроек пользователей…</p>
return <UsersContent key={JSON.stringify([mode, backendUrl])} />
}
function UsersContent() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" 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<AppUser[]>(INIT_USERS) const [users, setUsers] = useState<AppUser[]>(() => isLive ? [] : INIT_USERS)
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock) const [serverOptions, setServerOptions] = useState<UserServerOption[]>(() => isLive ? [] : toServerOptionsFromMock())
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(isLive && canRead)
const [usersReadAt, setUsersReadAt] = useState<number | null>(null)
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
const [usersError, setUsersError] = useState<string | null>(null)
const [serversError, setServersError] = useState<string | null>(null)
const loadController = useRef<AbortController | null>(null)
const loadRevision = useRef(0)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [editUser, setEditUser] = useState<AppUser | null>(null) const [editUser, setEditUser] = useState<AppUser | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null) const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
const loadLive = useCallback(async () => { 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) setLoading(true)
try { const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
const [list, srvs] = await Promise.all([ const [list, srvs] = await Promise.allSettled([
listAppUsers(backendUrl), listAppUsers(backendUrl, { signal }).then(rows => {
listServers(backendUrl), if (current()) { setUsers(rows); setUsersReadAt(Date.now()); setUsersError(null) }
]) return rows
setUsers(list) }).catch(error => {
setServerOptions(srvs.map((s) => ({ if (current()) setUsersError(error instanceof Error ? error.message : "Не удалось прочитать пользователей.")
id: String(s.id), throw error
name: s.name || s.host, }),
host: s.host, canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(raw => {
site: s.site, const rows = serverListSchema.parse(raw)
country: s.country, if (new Set(rows.map(row => row.id)).size !== rows.length || rows.some(row => row.id <= 0)) throw new Error("Некорректный каталог серверов.")
status: s.status, 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,
}))) })))
} catch (err) { setServersReadAt(Date.now()); setServersError(null)
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей")
} finally {
setLoading(false)
} }
}, [backendUrl]) 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(() => { useEffect(() => {
if (!isLive) { let cancelled = false
setUsers(INIT_USERS) if (isLive && canRead) queueMicrotask(() => { if (!cancelled) void loadLive() })
setServerOptions(toServerOptionsFromMock()) return () => { cancelled = true; loadController.current?.abort() }
return }, [isLive, canRead, loadLive])
}
void loadLive()
}, [isLive, 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 boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
const activeCount = users.filter((u) => u.active).length const activeCount = users.filter((u) => u.active).length
@@ -129,6 +160,7 @@ export default function UsersPage() {
} }
const handleSave = async (form: AppUserForm) => { const handleSave = async (form: AppUserForm) => {
if (!writeReady || saving) return
if (!isLive) { if (!isLive) {
if (!editUser) { if (!editUser) {
const id = `u${Date.now()}` const id = `u${Date.now()}`
@@ -188,7 +220,7 @@ export default function UsersPage() {
} }
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) return if (!deleteTarget || !writeReady) return
if (!isLive) { if (!isLive) {
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id)) setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
setDeleteTarget(null) setDeleteTarget(null)
@@ -210,13 +242,24 @@ export default function UsersPage() {
<PageHeader <PageHeader
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]} crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
actions={ actions={
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}> <>
<PlusIcon className="size-4" />Пригласить {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) }}>
<PlusIcon className="size-4" />Добавить пользователя
</Button> </Button>
</>
} }
/> />
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5"> <div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
{!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>}
{isLive && !canReadServers && <p role="status">Каталог серверов недоступен: требуется mm:servers:read. Изменения приостановлены.</p>}
{usersError && <div role="alert" aria-label="Пользователи" className="rounded-lg border border-destructive/30 p-3 text-sm"><p>{usersKnown ? "Не удалось обновить пользователей. Последний список сохранён." : "Пользователи пока неизвестны."}</p><p>{usersError}</p></div>}
{serversError && <div role="alert" aria-label="Серверы пользователей" className="rounded-lg border border-destructive/30 p-3 text-sm"><p>{serversKnown ? "Не удалось обновить серверы. Последний каталог сохранён." : "Каталог серверов пока неизвестен."} Изменения приостановлены.</p><p>{serversError}</p></div>}
{isLive && usersReadAt !== null && <p role="status" className="text-xs text-muted-foreground">Пользователи получены: {new Date(usersReadAt).toLocaleTimeString("ru-RU")}{loading ? " · обновляются" : ""}</p>}
{isLive && loading && !usersKnown && <p role="status">Загрузка пользователей…</p>}
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */} {/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка пользователей" aria-label="Сводка пользователей"
@@ -224,21 +267,21 @@ export default function UsersPage() {
{ {
id: "all", id: "all",
label: "Пользователи", label: "Пользователи",
value: users.length, value: usersKnown ? users.length : "—",
icon: <UsersIcon className="size-4" />, icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "active", id: "active",
label: "Активные", label: "Активные",
value: activeCount, value: usersKnown ? activeCount : "—",
icon: <UserCheckIcon className="size-4" />, icon: <UserCheckIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "ifaces", id: "ifaces",
label: "Привязанные ifaces", label: "Привязанные интерфейсы",
value: boundCount, value: usersKnown ? boundCount : "—",
icon: <CableIcon className="size-4" />, icon: <CableIcon className="size-4" />,
iconClassName: "text-info", 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 */} {/* 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 */}
<DataPageCard> {canRead && (usersKnown || loading) && <DataPageCard>
<UsersDataGrid <UsersDataGrid
users={users} users={users}
serversCount={serverOptions.length} serversCount={serversKnown ? serverOptions.length : null}
allSectionsCount={ALL_SECTIONS.length} allSectionsCount={ALL_SECTIONS.length}
isLoading={loading} isLoading={loading && !usersKnown}
actionsDisabled={!writeReady || saving}
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }} onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
onDelete={setDeleteTarget} onDelete={setDeleteTarget}
/> />
</DataPageCard> </DataPageCard>}
</div> </div>
<UserSheet <UserSheet
+10 -2
View File
@@ -44,9 +44,10 @@ function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
interface UsersDataGridProps { interface UsersDataGridProps {
users: AppUser[] users: AppUser[]
serversCount: number serversCount: number | null
allSectionsCount?: number allSectionsCount?: number
isLoading?: boolean isLoading?: boolean
actionsDisabled?: boolean
onEdit: (user: AppUser) => void onEdit: (user: AppUser) => void
onDelete: (user: AppUser) => void onDelete: (user: AppUser) => void
} }
@@ -56,6 +57,7 @@ function UsersDataGrid({
serversCount, serversCount,
allSectionsCount = ALL_SECTIONS.length, allSectionsCount = ALL_SECTIONS.length,
isLoading, isLoading,
actionsDisabled,
onEdit, onEdit,
onDelete, onDelete,
}: UsersDataGridProps) { }: UsersDataGridProps) {
@@ -182,6 +184,9 @@ function UsersDataGrid({
variant="ghost" variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground" className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(u)} onClick={() => onEdit(u)}
type="button"
disabled={actionsDisabled}
aria-label={"Редактировать пользователя " + u.name}
title="Редактировать" title="Редактировать"
> >
<PencilIcon className="size-3.5" /> <PencilIcon className="size-3.5" />
@@ -191,6 +196,9 @@ function UsersDataGrid({
variant="ghost" variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-destructive" className="size-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(u)} onClick={() => onDelete(u)}
type="button"
disabled={actionsDisabled}
aria-label={"Удалить пользователя " + u.name}
title="Удалить" title="Удалить"
> >
<TrashIcon className="size-3.5" /> <TrashIcon className="size-3.5" />
@@ -205,7 +213,7 @@ function UsersDataGrid({
}, },
}, },
], ],
[allSectionsCount, onDelete, onEdit, serversCount], [allSectionsCount, actionsDisabled, onDelete, onEdit, serversCount],
) )
const table = useTable({ const table = useTable({
@@ -32,7 +32,7 @@ const TYPE_ICON: Record<InterfaceType, { icon: typeof CableIcon; className: stri
interface UsersExpandedDetailProps { interface UsersExpandedDetailProps {
user: AppUser user: AppUser
serversCount: number serversCount: number | null
allSectionsCount?: number allSectionsCount?: number
} }
@@ -41,7 +41,7 @@ function UsersExpandedDetail({
serversCount, serversCount,
allSectionsCount = ALL_SECTIONS.length, allSectionsCount = ALL_SECTIONS.length,
}: UsersExpandedDetailProps) { }: UsersExpandedDetailProps) {
const access = summarizeUserAccess(user, serversCount, allSectionsCount) const access = summarizeUserAccess(user, serversCount ?? 0, allSectionsCount)
const groups = groupBindingsByServer(user.bindings) const groups = groupBindingsByServer(user.bindings)
return ( return (
@@ -56,7 +56,7 @@ function UsersExpandedDetail({
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Серверы:{" "} Серверы:{" "}
<span className="font-mono text-foreground"> <span className="font-mono text-foreground">
{access.serversGranted}/{access.serversTotal} {serversCount === null ? "каталог неизвестен" : access.serversGranted + "/" + access.serversTotal}
</span> </span>
</span> </span>
{access.writeKind === "full" ? ( {access.writeKind === "full" ? (
+9 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1; карта API и сценариев сохранена | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.2; загрузка пользователей принята локально, операции далее |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 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) ## Блок 11А.1 — подготовка администрирования (2026-10-01)
- Сохранён docs/ux-ui-stage-11a.md: референсы, схема, порядок пользователей/контейнеров/сертификатов/backup, API и точки завершения. Чтение и многошаговые операции пользователей разделены на проверяемые блоки. - Сохранён docs/ux-ui-stage-11a.md: референсы, схема, порядок пользователей/контейнеров/сертификатов/backup, API и точки завершения. Чтение и многошаговые операции пользователей разделены на проверяемые блоки.
+9
View File
@@ -40,3 +40,12 @@
- Серверный каталог — зависимость формы прав/привязок; отсутствие доступа или отказ не должен обнулять сохранённые разрешения. - Серверный каталог — зависимость формы прав/привязок; отсутствие доступа или отказ не должен обнулять сохранённые разрешения.
- Backend/моки не подтверждают реальный Portal, PostgreSQL, RouterOS, выдачу сертификатов, восстановление backup или доступность S3. Реальную приёмку перечислять отдельно. - Backend/моки не подтверждают реальный Portal, PostgreSQL, RouterOS, выдачу сертификатов, восстановление backup или доступность S3. Реальную приёмку перечислять отдельно.
- Оповещения, сбор данных, интеграции, релизы, настройки и расписание диагностики — 11Б. - Оповещения, сбор данных, интеграции, релизы, настройки и расписание диагностики — 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/БД ещё не проверены.
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", 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, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
+8
View File
@@ -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"}},
})
+3 -3
View File
@@ -13,7 +13,7 @@ import { z } from "zod"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import type { AppUser, CatalogIface, InterfaceBinding } from "@/lib/users" 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 userPayload = z.object({ user: appUserReadSchema })
const bindingPayload = z.object({ binding: userBindingSchema }) const bindingPayload = z.object({ binding: userBindingSchema })
const catalogPayload = z.object({ interfaces: z.array(catalogInterfaceSchema) }) 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<AppUser[]> { export async function listAppUsers(baseUrl: string, init?: RequestInit): Promise<AppUser[]> {
const payload = await requestJson<unknown>(baseUrl, "/api/users") const payload = await requestJson<unknown>(baseUrl, "/api/users", init)
return usersListPayload.parse(payload).users.map(toFrontendUser) return usersListPayload.parse(payload).users.map(toFrontendUser)
} }
+13
View File
@@ -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:"[email protected]",email:"[email protected]",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<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]}:[]})})
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)
})
+43
View File
@@ -0,0 +1,43 @@
import { test, expect, type Page } from "@playwright/test"
const user={id:"u-test",name:"Настоящий пользователь",login:"[email protected]",email:"[email protected]",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<void>(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<void>(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()
})