Files
MikrotikManager/app/(main)/users/page.tsx
T

342 lines
14 KiB
TypeScript

"use client"
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"
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,
bindingDiffKey,
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 {
createAppUser,
createUserBinding,
deleteAppUser,
deleteUserBinding,
listAppUsers,
updateAppUser,
} from "@/shared/api/users"
import { ApiClientError } from "@/shared/api/http-client"
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 [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 [sheetOpen, setSheetOpen] = useState(false)
const [editUser, setEditUser] = useState<AppUser | null>(null)
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(error instanceof Error ? error.message : "Не удалось прочитать пользователей.")
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(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(() => {
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 applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
const nextKeys = new Set(next.map(bindingDiffKey))
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
for (const b of prev) {
if (!nextKeys.has(bindingDiffKey(b))) {
await deleteUserBinding(backendUrl, userId, b.id)
}
}
for (const b of next) {
if (!prevKeys.has(bindingDiffKey(b))) {
await createUserBinding(backendUrl, userId, {
serverId: Number(b.serverId),
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: b.peerPublicKey,
peerName: b.peerName,
comment: b.comment,
})
}
}
}
const handleSave = async (form: AppUserForm) => {
if (!writeReady || saving) return
if (!isLive) {
if (!editUser) {
const id = `u${Date.now()}`
const created: AppUser = {
id,
...form,
last: "только что",
avatar: userInitials(form.name),
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
}
setUsers((prev) => [...prev, created])
} else {
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
}
setSheetOpen(false)
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
return
}
setSaving(true)
try {
if (!editUser) {
const created = await createAppUser(backendUrl, {
name: form.name,
login: form.login,
email: form.email,
role: form.role,
active: form.active,
avatar: userInitials(form.name),
sections: form.sections,
servers: form.servers,
})
await applyBindingsDiff(created.id, form.bindings, [])
await loadLive()
} else {
await updateAppUser(backendUrl, editUser.id, {
name: form.name,
login: form.login,
email: form.email,
role: form.role,
active: form.active,
avatar: userInitials(form.name),
sections: form.sections,
servers: form.servers,
})
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
await loadLive()
}
setSheetOpen(false)
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
} catch (err) {
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
toast.error(msg)
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteTarget || !writeReady) return
if (!isLive) {
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
setDeleteTarget(null)
toast.success("Пользователь удалён")
return
}
try {
await deleteAppUser(backendUrl, deleteTarget.id)
setDeleteTarget(null)
await loadLive()
toast.success("Пользователь удалён")
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось удалить")
}
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
actions={
<>
{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>
</>
}
/>
<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 */}
<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>
<UsersDataGrid
users={users}
serversCount={serversKnown ? serverOptions.length : null}
allSectionsCount={ALL_SECTIONS.length}
isLoading={loading && !usersKnown}
actionsDisabled={!writeReady || saving}
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
onDelete={setDeleteTarget}
/>
</DataPageCard>}
</div>
<UserSheet
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
open={sheetOpen}
user={editUser}
users={users}
servers={serverOptions}
isLive={isLive}
backendUrl={backendUrl}
saving={saving}
onSave={(f) => { void handleSave(f) }}
onClose={() => setSheetOpen(false)}
/>
{deleteTarget && (
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(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>
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
}