feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,365 @@
|
||||
"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 <p role="status">Загрузка настроек пользователей…</p>
|
||||
return <UsersContent key={JSON.stringify([mode, backendUrl])} />
|
||||
}
|
||||
|
||||
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<AppUser[]>(() => 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<UserServerOption[]>(() => isLive ? [] : toServerOptionsFromMock())
|
||||
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 [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saveBlocked, setSaveBlocked] = useState(false)
|
||||
const [saveResume, setSaveResume] = useState(false)
|
||||
const saveCheckpoint = useRef<UserSaveCheckpoint | null>(null)
|
||||
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 formReturnFocus = useRef<HTMLElement | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editUser, setEditUser] = useState<AppUser | null>(null)
|
||||
useEffect(() => {
|
||||
if (sheetOpen || !formReturnFocus.current) return
|
||||
const label = formReturnFocus.current.getAttribute("aria-label")
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === label)
|
||||
;(target ?? document.querySelector<HTMLButtonElement>('button[aria-label="Добавить пользователя"]'))?.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [sheetOpen])
|
||||
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(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<unknown>(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 (
|
||||
<div className="flex flex-col h-full min-w-0">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
||||
actions={
|
||||
<>
|
||||
{isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить пользователей" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить</Button>}
|
||||
<Button type="button" size="sm" aria-label="Добавить пользователя" disabled={!writeReady || saving || deleting} onClick={() => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(null); setSheetOpen(true) }}>
|
||||
<PlusIcon aria-hidden="true" className="size-4" />Добавить <span className="hidden sm:inline">пользователя</span>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 min-w-0 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>}
|
||||
{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 */}
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка пользователей"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Пользователи",
|
||||
value: usersKnown ? users.length : "—",
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
label: "Активные",
|
||||
value: usersKnown ? activeCount : "—",
|
||||
icon: <UserCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
label: "Привязанные интерфейсы",
|
||||
value: usersKnown ? boundCount : "—",
|
||||
icon: <CableIcon className="size-4" />,
|
||||
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) && <DataPageCard>
|
||||
<DataPageToolbar search={userSearch} onSearchChange={setUserSearch} searchLabel="Поиск по имени, логину или email" searchPlaceholder="Поиск по имени, логину или email…" countLabel={usersKnown ? `${visibleUsers.length} из ${users.length}` : "Данные пока неизвестны"} />
|
||||
<UsersDataGrid
|
||||
users={visibleUsers}
|
||||
emptyTitle={userSearch.trim() ? "Нет совпадений" : "Нет пользователей"}
|
||||
emptyDescription={userSearch.trim() ? "Измените поиск" : "Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"}
|
||||
serversCount={serversKnown ? serverOptions.length : null}
|
||||
allSectionsCount={ALL_SECTIONS.length}
|
||||
isLoading={loading && !usersKnown}
|
||||
actionsDisabled={!writeReady || saving || deleting}
|
||||
onEdit={(u) => { 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) }}
|
||||
/>
|
||||
</DataPageCard>}
|
||||
</div>
|
||||
|
||||
<UserSheet
|
||||
returnFocus={formReturnFocus}
|
||||
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||||
open={sheetOpen}
|
||||
user={editUser}
|
||||
users={users}
|
||||
servers={serverOptions}
|
||||
isLive={isLive}
|
||||
backendUrl={backendUrl}
|
||||
saving={saving}
|
||||
saveError={saveError}
|
||||
saveBlocked={saveBlocked}
|
||||
saveResume={saveResume}
|
||||
onCheckCatalogs={() => void loadLive()}
|
||||
onSave={(f) => { void handleSave(f) }}
|
||||
onClose={() => { if (!saving) { setSheetOpen(false); saveCheckpoint.current = null } }}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v && !deleting) { setDeleteTarget(null); setDeleteError(null) } }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<TrashIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
{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>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user