"use client" import { useCallback, useEffect, useRef, useState, useMemo } from "react" import { PageHeader } from "@/components/page-header" import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageCard } from "@/components/data-page-card" import { UsersDataGrid } from "@/components/data-grids/users-data-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { UserSheet } from "@/components/users/user-sheet" import { Button } from "@/components/ui/button" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { servers as mockServers } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { ALL_SECTIONS, INIT_USERS, userInitials, type AppUser, type AppUserForm, type UserServerOption, } from "@/lib/users" import { serverListSchema } from "@mmapp/contracts/servers" import { requestJson } from "@/shared/api/http-client" import { can } from "@/lib/auth" import { userApiError, createAppUser, getAppUser, createUserBinding, deleteAppUser, deleteUserBinding, listAppUsers, updateAppUser, } from "@/shared/api/users" import { saveUserSteps, userProfileRequest, type UserSaveCheckpoint } from "@/lib/user-save" import { toast } from "sonner" import { CableIcon, PlusIcon, TrashIcon, UserCheckIcon, UsersIcon, } from "lucide-react" function toServerOptionsFromMock(): UserServerOption[] { return mockServers.map((s) => ({ id: s.id, name: s.name, host: s.host, site: s.site, country: s.country, status: s.status === "unknown" ? null : s.status, })) } 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(() => isLive ? [] : INIT_USERS) const [userSearch, setUserSearch] = useState("") const visibleUsers = useMemo(() => { const query = userSearch.trim().toLocaleLowerCase("ru-RU") return query ? users.filter(user => [user.name, user.login, user.email].some(value => value.toLocaleLowerCase("ru-RU").includes(query))) : users }, [users, userSearch]) 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 [saveError, setSaveError] = useState(null) const [saveBlocked, setSaveBlocked] = useState(false) const [saveResume, setSaveResume] = useState(false) const saveCheckpoint = useRef(null) const [deleting, setDeleting] = useState(false) const [deleteError, setDeleteError] = useState(null) const [operationNotice, setOperationNotice] = useState(null) const mutationLock = useRef(false) const mutationController = useRef(null) const alive = useRef(true) useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, []) const formReturnFocus = useRef(null) const [sheetOpen, setSheetOpen] = useState(false) const [editUser, setEditUser] = useState(null) useEffect(() => { if (sheetOpen || !formReturnFocus.current) return const label = formReturnFocus.current.getAttribute("aria-label") const frame = requestAnimationFrame(() => { const target = Array.from(document.querySelectorAll("button[aria-label]")).find(button => button.getAttribute("aria-label") === label) ;(target ?? document.querySelector('button[aria-label="Добавить пользователя"]'))?.focus() }) return () => cancelAnimationFrame(frame) }, [sheetOpen]) 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) 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(userApiError(error)) 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(userApiError(error)) throw error }) : Promise.resolve(null), ]) if (!current()) return false setLoading(false) return list.status === "fulfilled" && srvs.status === "fulfilled" }, [isLive, canRead, canReadServers, backendUrl]) useEffect(() => { 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 const handleSave = async (form: AppUserForm) => { if (!writeReady || mutationLock.current || saveBlocked) return if (!isLive) { if (!editUser) { const id = "u" + Date.now() setUsers(prev => [...prev, { id, ...form, last: "—", avatar: userInitials(form.name), bindings: form.bindings.map((b, i) => ({ ...b, id: "b" + id + "-" + i, userId: id })) }]) } else setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u)) setSheetOpen(false); toast.success("Профиль сохранён в демо."); return } mutationLock.current = true const controller = new AbortController() mutationController.current = controller const current = () => alive.current && !controller.signal.aborted const init = () => ({ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) }) const checkpoint = saveCheckpoint.current ?? { user: editUser } saveCheckpoint.current = checkpoint setSaving(true); setSaveError(null); setOperationNotice(null) try { const saved = await saveUserSteps(form, checkpoint, { create: f => createAppUser(backendUrl, userProfileRequest(f), init()), update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f), init()), read: id => getAppUser(backendUrl, id, init()), removeBinding: (userId, id) => deleteUserBinding(backendUrl, userId, id, init()), addBinding: (userId, b) => createUserBinding(backendUrl, userId, { serverId: Number(b.serverId), interfaceName: b.interfaceName, interfaceType: b.interfaceType, peerPublicKey: b.peerPublicKey, peerName: b.peerName, comment: b.comment, }, init()), }, current, state => { if (!current()) return setSaveBlocked(!!state.createUncertain) if (state.user) { if (!editUser) setSaveResume(true) const confirmed = state.user setUsers(prev => prev.some(u => u.id === confirmed.id) ? prev.map(u => u.id === confirmed.id ? confirmed : u) : [...prev, confirmed]) } }) if (!current() || !saved) return setSheetOpen(false); saveCheckpoint.current = null setOperationNotice("Профиль и привязки подтверждены API.") toast.success("Пользователь сохранён.") const refreshed = await loadLive() if (current() && !refreshed) setOperationNotice("Профиль и привязки подтверждены API. Обновление каталогов не удалось; повторите чтение.") } catch (error) { if (!current()) return const detail = userApiError(error) setSaveError(checkpoint.createUncertain ? "Результат создания неизвестен. Повтор заблокирован: проверьте список пользователей. " + detail : checkpoint.bindingsNeedRead ? "Профиль подтверждён API; привязки сохранены частично. При повторе сначала прочитаем их текущее состояние. " + detail : detail) } finally { if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false } if (alive.current) setSaving(false) } } const handleDelete = async () => { if (!deleteTarget || !writeReady || mutationLock.current) return const target = deleteTarget if (!isLive) { 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, 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) setOperationNotice("Удаление пользователя подтверждено API.") toast.success("Пользователь удалён.") const refreshed = await loadLive() if (current() && !refreshed) setOperationNotice("Удаление пользователя подтверждено API. Обновление каталогов не удалось; повторите чтение.") } catch (error) { if (current()) setDeleteError(userApiError(error)) } finally { if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false } if (alive.current) setDeleting(false) } } return (
{isLive && } } />
{operationNotice &&

{operationNotice}

} {!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: usersKnown ? activeCount : "—", icon: , iconClassName: "text-success", }, { id: "ifaces", label: "Привязанные интерфейсы", value: usersKnown ? boundCount : "—", icon: , iconClassName: "text-info", }, ]} /> {/* 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) && { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(u); setSheetOpen(true) }} onDelete={u => { setDeleteError(null); setDeleteTarget(u) }} /> }
void loadLive()} onSave={(f) => { void handleSave(f) }} onClose={() => { if (!saving) { setSheetOpen(false); saveCheckpoint.current = null } }} /> {deleteTarget && ( { if (!v && !deleting) { setDeleteTarget(null); setDeleteError(null) } }}> Удалить пользователя? {deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены. {deleteError &&

{deleteError}

} { setDeleteTarget(null); setDeleteError(null) }}>Отмена { void handleDelete() }}> {deleting ? "Удаление…" : "Удалить"}
)}
) }