298 lines
9.6 KiB
TypeScript
298 lines
9.6 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, 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 { listServers } from "@/shared/api/servers"
|
|
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 } = useDataSource()
|
|
const isLive = mode === "live"
|
|
|
|
const [users, setUsers] = useState<AppUser[]>(INIT_USERS)
|
|
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock)
|
|
const [loading, setLoading] = useState(false)
|
|
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 () => {
|
|
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])
|
|
|
|
useEffect(() => {
|
|
if (!isLive) {
|
|
setUsers(INIT_USERS)
|
|
setServerOptions(toServerOptionsFromMock())
|
|
return
|
|
}
|
|
void loadLive()
|
|
}, [isLive, loadLive])
|
|
|
|
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 (!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) 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={
|
|
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
|
|
<PlusIcon className="size-4" />Пригласить
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
|
|
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
|
|
<KpiStatGrid
|
|
aria-label="Сводка пользователей"
|
|
items={[
|
|
{
|
|
id: "all",
|
|
label: "Пользователи",
|
|
value: users.length,
|
|
icon: <UsersIcon className="size-4" />,
|
|
iconClassName: "text-muted-foreground",
|
|
},
|
|
{
|
|
id: "active",
|
|
label: "Активные",
|
|
value: activeCount,
|
|
icon: <UserCheckIcon className="size-4" />,
|
|
iconClassName: "text-success",
|
|
},
|
|
{
|
|
id: "ifaces",
|
|
label: "Привязанные ifaces",
|
|
value: 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 */}
|
|
<DataPageCard>
|
|
<UsersDataGrid
|
|
users={users}
|
|
serversCount={serverOptions.length}
|
|
allSectionsCount={ALL_SECTIONS.length}
|
|
isLoading={loading}
|
|
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>
|
|
)
|
|
}
|