feat(users): implement user management features and database schema
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m57s
Docker images / frontend-image (push) Successful in 4m8s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 44s
Docker images / publish-release (push) Successful in 11s

Added user management functionality, including the creation of app_users and user_interface_bindings tables in the database. Implemented API routes for user data retrieval and permissions handling. Enhanced the traffic monitoring system to include user traffic statistics and interface bindings. Updated relevant components and services to support the new user features, improving overall application functionality and user experience.
This commit is contained in:
Denozordec
2026-09-06 19:20:05 +07:00
parent fe32c9313a
commit b3e50a1f5f
34 changed files with 2883 additions and 1158 deletions
+4 -1
View File
@@ -35,6 +35,7 @@ import {
ShieldCheckIcon,
BoxIcon,
BadgeCheckIcon,
UsersIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
@@ -70,6 +71,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
label: "Управление",
items: [
{ title: "Серверы", url: "/servers", icon: <ServerIcon /> },
{ title: "Пользователи", url: "/users", icon: <UsersIcon /> },
{ title: "Фильтры", url: "/filters", icon: <FilterIcon /> },
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
@@ -102,7 +104,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
},
]
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number }
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number; users?: number }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { mode, backendUrl, prefsHydrated } = useDataSource()
@@ -161,6 +163,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (!liveCounts) return undefined
if (url === "/servers") return formatSidebarBadgeCount(liveCounts.servers)
if (url === "/users") return formatSidebarBadgeCount(liveCounts.users ?? 0)
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
+2 -1
View File
@@ -8,7 +8,7 @@ import {
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon,
} from "lucide-react"
// ─── Command item definition ──────────────────────────────────────────────────
@@ -36,6 +36,7 @@ const ALL_ITEMS: CommandItem[] = [
{ id: "communities", title: "BGP Communities", group: "Данные", url: "/communities", icon: <TagIcon />, keywords: ["community","bgp","теги"] },
// Управление
{ id: "servers", title: "Серверы", group: "Управление", url: "/servers", icon: <ServerIcon />, keywords: ["router","mikrotik","сервер","routeros"] },
{ id: "users", title: "Пользователи", group: "Управление", url: "/users", icon: <UsersIcon />, keywords: ["user","клиент","оператор","привязка","интерфейс"] },
{ id: "filters", title: "Фильтры", group: "Управление", url: "/filters", icon: <FilterIcon />, keywords: ["filter","routing","маршрутизация"] },
{ id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: <RouteIcon />, keywords: ["recursive","route","static","маршруты"] },
{ id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: <ShieldIcon />, keywords: ["rules","правила","брандмауэр","acl"] },
@@ -1,167 +0,0 @@
"use client"
import { useMemo } from "react"
import type { Server } from "@/lib/data"
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
import { cn } from "@/lib/utils"
type PermLevel = "none" | "read" | "write"
type Role = "admin" | "operator" | "viewer"
interface SectionPerm {
section: string
level: PermLevel
}
interface ServerPerm {
serverId: string
level: PermLevel
}
export interface AccessSummaryUser {
id: string
name: string
avatar: string
active: boolean
role: Role
sections: SectionPerm[]
servers: ServerPerm[]
}
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
className={cn(
"size-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{avatar}
</div>
)
}
interface SettingsAccessSummaryDataGridProps {
users: AccessSummaryUser[]
servers: Server[]
allSectionsCount: number
}
function SettingsAccessSummaryDataGrid({
users,
servers,
allSectionsCount,
}: SettingsAccessSummaryDataGridProps) {
const columns = useMemo<CompactDataGridColumn<AccessSummaryUser>[]>(
() => [
{
id: "name",
header: "Пользователь",
accessorKey: "name",
cell: (u) => (
<div className="flex items-center gap-2">
<AvatarCircle avatar={u.avatar} active={u.active} />
<span className="text-sm font-medium">{u.name}</span>
</div>
),
},
{
id: "sections",
header: "Разделы",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
const readSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "read").map((s) => s.section)
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({allSectionsCount})
</span>
) : (
<span>{readSections.length + writeSections.length} из {allSectionsCount}</span>
)}
</span>
)
},
},
{
id: "servers",
header: "Серверы",
enableSorting: false,
cell: (u) => {
const accessServers =
u.role === "admin"
? servers
: servers.filter((s) => u.servers.find((p) => p.serverId === s.id && p.level !== "none"))
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({servers.length})
</span>
) : (
<span>
{accessServers.length} из {servers.length}
</span>
)}
</span>
)
},
},
{
id: "write",
header: "Права записи",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
return (
<div className="flex flex-wrap gap-1">
{u.role === "admin" ? (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">
Полный доступ
</span>
) : writeSections.length === 0 ? (
<span className="text-[10px] text-muted-foreground">Только просмотр</span>
) : (
<>
{writeSections.slice(0, 3).map((s) => (
<span
key={s}
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
>
{s}
</span>
))}
{writeSections.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
)}
</>
)}
</div>
)
},
},
],
[allSectionsCount, servers],
)
return (
<CompactDataGrid
data={users}
columns={columns}
emptyTitle="Нет пользователей"
/>
)
}
export { SettingsAccessSummaryDataGrid, type SettingsAccessSummaryDataGridProps }
+244
View File
@@ -0,0 +1,244 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/reui/badge"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { UsersExpandedDetail } from "@/components/data-grids/users-expanded-detail"
import { EmptyState } from "@/components/empty-state"
import { ALL_SECTIONS, ROLE_LABEL, type AppUser } from "@/lib/users"
import {
CableIcon,
ChevronDownIcon,
ChevronRightIcon,
PencilIcon,
TrashIcon,
UsersIcon,
} from "lucide-react"
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
className={cn(
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold shrink-0 bg-gradient-to-br from-blue-500 to-violet-500",
!active && "opacity-50",
)}
>
{avatar}
</div>
)
}
interface UsersDataGridProps {
users: AppUser[]
serversCount: number
allSectionsCount?: number
isLoading?: boolean
onEdit: (user: AppUser) => void
onDelete: (user: AppUser) => void
}
function UsersDataGrid({
users,
serversCount,
allSectionsCount = ALL_SECTIONS.length,
isLoading,
onEdit,
onDelete,
}: UsersDataGridProps) {
const columns = useMemo<ColumnDef<AppUser>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пользователь" className="ml-1" />
),
cell: ({ row }) => {
const u = row.original
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
{expanded ? (
<ChevronDownIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
)}
<AvatarCircle avatar={u.avatar} active={u.active} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{u.name}</p>
<p className="text-xs font-mono text-muted-foreground truncate">{u.email || u.login}</p>
</div>
</div>
)
},
meta: {
headerTitle: "Пользователь",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: AppUser) => (
<UsersExpandedDetail
user={row}
serversCount={serversCount}
allSectionsCount={allSectionsCount}
/>
),
},
},
{
id: "role",
accessorKey: "role",
header: ({ column }) => <DataGridSortHeader column={column} title="Роль" />,
cell: ({ row }) => (
<Badge
variant={row.original.role === "admin" ? "primary-light" : row.original.role === "operator" ? "info-light" : "outline"}
size="sm"
>
{ROLE_LABEL[row.original.role]}
</Badge>
),
meta: {
headerTitle: "Роль",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "last",
accessorKey: "last",
header: ({ column }) => <DataGridSortHeader column={column} title="Последний вход" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">{row.original.last}</span>
),
meta: {
headerTitle: "Последний вход",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "ifaces",
accessorFn: (u) => u.bindings.length,
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
<CableIcon className="size-3.5" />
{row.original.bindings.length}
</span>
),
meta: {
headerTitle: "Интерфейсы",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "status",
accessorKey: "active",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<span
className={cn(
"text-[11px] font-medium",
row.original.active ? "text-success" : "text-muted-foreground",
)}
>
{row.original.active ? "Активен" : "Заблокирован"}
</span>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const u = row.original
return (
<div
className="flex items-center justify-end gap-0.5"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(u)}
title="Редактировать"
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(u)}
title="Удалить"
>
<TrashIcon className="size-3.5" />
</Button>
</div>
)
},
meta: {
headerTitle: "Действия",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[allSectionsCount, onDelete, onEdit, serversCount],
)
const table = useReactTable({
data: users,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (!isLoading && users.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
title="Нет пользователей"
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
className="border-0 py-12"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={users.length}
isLoading={isLoading}
loadingMode="skeleton"
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
/>
)
}
export { UsersDataGrid, type UsersDataGridProps }
@@ -0,0 +1,126 @@
"use client"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import {
ALL_SECTIONS,
groupBindingsByServer,
IFACE_TYPE_LABEL,
summarizeUserAccess,
type AppUser,
type InterfaceType,
} from "@/lib/users"
import { CableIcon, NetworkIcon, ShieldIcon } from "lucide-react"
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
ether: "outline",
gre: "info-light",
wg: "success-light",
other: "secondary",
}
const TYPE_ICON: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
ether: { icon: CableIcon, className: "text-muted-foreground" },
gre: { icon: NetworkIcon, className: "text-info" },
wg: { icon: ShieldIcon, className: "text-success" },
other: { icon: CableIcon, className: "text-muted-foreground" },
}
interface UsersExpandedDetailProps {
user: AppUser
serversCount: number
allSectionsCount?: number
}
function UsersExpandedDetail({
user,
serversCount,
allSectionsCount = ALL_SECTIONS.length,
}: UsersExpandedDetailProps) {
const access = summarizeUserAccess(user, serversCount, allSectionsCount)
const groups = groupBindingsByServer(user.bindings)
return (
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs">
<span className="text-muted-foreground">
Разделы:{" "}
<span className="font-mono text-foreground">
{access.sectionsGranted}/{access.sectionsTotal}
</span>
</span>
<span className="text-muted-foreground">
Серверы:{" "}
<span className="font-mono text-foreground">
{access.serversGranted}/{access.serversTotal}
</span>
</span>
{access.writeKind === "full" ? (
<Badge variant="success-light" size="sm">Полный доступ</Badge>
) : access.writeKind === "none" ? (
<Badge variant="outline" size="sm">Только просмотр</Badge>
) : (
<span className="inline-flex flex-wrap items-center gap-1">
{access.writeSections.map((section) => (
<Badge key={section} variant="info-light" size="sm">{section}</Badge>
))}
</span>
)}
</div>
{groups.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет привязанных интерфейсов</p>
) : (
groups.map((g) => {
const items = g.types.flatMap((tg) => tg.items)
return (
<div key={g.serverId}>
<div className="flex items-center gap-3 mb-3">
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
{g.serverName}
</p>
<Flag code={g.serverCountry} size={12} />
<span className="text-[11px] font-mono text-muted-foreground">{g.serverSite}</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
{items.map((b) => {
const meta = TYPE_ICON[b.interfaceType]
const Icon = meta.icon
return (
<div
key={b.id}
className="flex items-start gap-2 rounded-md border px-3 py-2.5"
>
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", meta.className)}>
<Icon />
</IconTile>
<div className="min-w-0">
<p className="text-xs font-mono font-medium leading-tight truncate">
{b.interfaceName}
</p>
<div className="flex flex-wrap items-center gap-1 mt-0.5">
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
{IFACE_TYPE_LABEL[b.interfaceType]}
</Badge>
{b.comment ? (
<span className="text-[10px] text-muted-foreground leading-tight truncate">
{b.comment}
</span>
) : null}
</div>
</div>
</div>
)
})}
</div>
</div>
)
})
)}
</div>
)
}
export { UsersExpandedDetail, TYPE_VARIANT, type UsersExpandedDetailProps }
+519
View File
@@ -0,0 +1,519 @@
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,
} from "@/components/ui/sheet"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import { Badge } from "@/components/reui/badge"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { cn } from "@/lib/utils"
import { listInterfaceCatalog } from "@/shared/api/users"
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
import {
catalogForServer,
defaultSections,
defaultServers,
IFACE_TYPE_LABEL,
PERM_COLOR,
PERM_OPTS,
ROLE_LABEL,
SECTION_GROUP_DEFS,
type AppUser,
type AppUserForm,
type CatalogIface,
type InterfaceBinding,
type InterfaceType,
type PermLevel,
type Role,
type UserServerOption,
} from "@/lib/users"
import {
LayoutDashboardIcon, EyeIcon, PlusIcon, ServerIcon, ShieldIcon,
TrashIcon, WrenchIcon,
} from "lucide-react"
const SECTION_GROUP_ICONS: Record<string, ReactNode> = {
"Обзор": <LayoutDashboardIcon className="size-3" />,
"Данные": <EyeIcon className="size-3" />,
"Управление": <WrenchIcon className="size-3" />,
"Инструменты": <ShieldIcon className="size-3" />,
"Система": <ServerIcon className="size-3" />,
}
function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) {
return (
<div className={cn("flex rounded border border-input overflow-hidden h-6", disabled && "opacity-40 pointer-events-none")}>
{PERM_OPTS.map((o, i) => (
<button
key={o.v}
type="button"
onClick={() => onChange(o.v)}
className={cn(
"px-2 text-[11px] font-medium transition-colors",
i < PERM_OPTS.length - 1 && "border-r border-input",
value === o.v
? o.v === "write" ? "bg-success text-white"
: o.v === "read" ? "bg-info text-white"
: "bg-muted-foreground/70 text-white"
: "text-muted-foreground hover:bg-muted",
)}
>
{o.label}
</button>
))}
</div>
)
}
function emptyForm(serverList: UserServerOption[]): AppUserForm {
return {
name: "", login: "", email: "", role: "viewer", active: true,
sections: defaultSections("viewer"),
servers: defaultServers("viewer", serverList),
bindings: [],
}
}
function toForm(user: AppUser): AppUserForm {
return {
name: user.name, login: user.login, email: user.email, role: user.role, active: user.active,
sections: user.sections.map((s) => ({ ...s })),
servers: user.servers.map((s) => ({ ...s })),
bindings: user.bindings.map((b) => ({ ...b })),
}
}
function isValidEmail(value: string) {
const email = value.trim()
return email.length > 0 && email.includes("@")
}
interface UserSheetProps {
open: boolean
user: AppUser | null
users: AppUser[]
servers: UserServerOption[]
isLive: boolean
backendUrl: string
saving?: boolean
onSave: (form: AppUserForm) => void
onClose: () => void
}
function UserSheet({
open, user, users, servers, isLive, backendUrl, saving, onSave, onClose,
}: UserSheetProps) {
const isCreate = user === null
const [sheetStep, setSheetStep] = useState(1)
const [form, setForm] = useState<AppUserForm>(() => (user ? toForm(user) : emptyForm(servers)))
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
const [catalog, setCatalog] = useState<CatalogIface[]>([])
const [selectedNames, setSelectedNames] = useState<string[]>([])
const [newComment, setNewComment] = useState("")
useEffect(() => {
if (!open) return
setForm(user ? toForm(user) : emptyForm(servers))
setSheetStep(1)
setErrors({})
setCatalogServerId(servers[0]?.id ?? "")
setSelectedNames([])
setNewComment("")
}, [open, user, servers])
useEffect(() => {
if (!open || !catalogServerId) {
setCatalog([])
return
}
if (!isLive) {
const occupancy = users.map((u) =>
u.id === user?.id ? { ...u, bindings: form.bindings } : u,
)
const withDraft = user
? occupancy
: [...occupancy, { id: "__draft__", name: "", login: "", email: "", role: "viewer" as const, last: "", avatar: "", active: true, sections: [], servers: [], bindings: form.bindings }]
setCatalog(catalogForServer(catalogServerId, withDraft))
return
}
let cancelled = false
void listInterfaceCatalog(backendUrl, catalogServerId)
.then((ifaces) => { if (!cancelled) setCatalog(ifaces) })
.catch(() => { if (!cancelled) setCatalog([]) })
return () => { cancelled = true }
}, [open, catalogServerId, isLive, backendUrl, users, user, form.bindings])
const setField = <K extends keyof AppUserForm>(k: K, v: AppUserForm[K]) => {
setForm((f) => ({ ...f, [k]: v }))
if (errors[k]) setErrors((e) => ({ ...e, [k]: undefined }))
}
const setRole = (role: Role) => {
setForm((f) => ({
...f,
role,
sections: defaultSections(role),
servers: servers.map((s) => ({ serverId: s.id, level: role === "admin" ? "write" : "read" })),
}))
}
const setSectionPerm = (section: string, level: PermLevel) => {
setForm((f) => ({
...f,
sections: f.sections.map((s) => s.section === section ? { ...s, level } : s),
}))
}
const setServerPerm = (serverId: string, level: PermLevel) => {
setForm((f) => ({
...f,
servers: f.servers.map((s) => s.serverId === serverId ? { ...s, level } : s),
}))
}
const setAllSections = (level: PermLevel) => {
setForm((f) => ({ ...f, sections: f.sections.map((s) => ({ ...s, level })) }))
}
const setAllServers = (level: PermLevel) => {
setForm((f) => ({ ...f, servers: f.servers.map((s) => ({ ...s, level })) }))
}
const submit = () => {
const email = form.email.trim()
const e: Partial<Record<keyof AppUserForm, string>> = {}
if (!form.name.trim()) e.name = "Обязательное поле"
if (!isValidEmail(email)) e.email = email ? "Укажите корректный email" : "Обязательное поле"
setErrors(e)
if (Object.keys(e).length) {
setSheetStep(1)
return
}
onSave({ ...form, email, login: email })
}
const isAdmin = form.role === "admin"
const catalogSrv = servers.find((s) => s.id === catalogServerId)
const addSelectedBindings = () => {
if (!catalogSrv || selectedNames.length === 0) return
const existing = new Set(form.bindings.map((b) => `${b.serverId}::${b.interfaceName}`))
const next: InterfaceBinding[] = [...form.bindings]
for (const name of selectedNames) {
const key = `${catalogSrv.id}::${name}`
if (existing.has(key)) continue
const iface = catalog.find((c) => c.name === name)
if (!iface) continue
if (iface.boundUserId && iface.boundUserId !== user?.id) continue
next.push({
id: `pending-${catalogSrv.id}-${name}`,
userId: user?.id ?? "",
serverId: catalogSrv.id,
serverName: catalogSrv.name,
serverSite: catalogSrv.site,
serverCountry: catalogSrv.country,
interfaceName: name,
interfaceType: iface.type,
comment: newComment.trim(),
})
}
setForm((f) => ({ ...f, bindings: next }))
setSelectedNames([])
setNewComment("")
}
const removeBinding = (id: string) => {
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
}
const toggleName = (name: string) => {
setSelectedNames((prev) => prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name])
}
const alreadyBoundHere = useMemo(
() => new Set(form.bindings.filter((b) => b.serverId === catalogServerId).map((b) => b.interfaceName)),
[form.bindings, catalogServerId],
)
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{isCreate ? "Новый пользователь" : "Редактировать пользователя"}</SheetTitle>
<SheetDescription>
{isCreate ? "Профиль, права и привязка интерфейсов" : "Права доступа и привязка интерфейсов"}
</SheetDescription>
</SheetHeader>
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Профиль</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">Разделы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">Серверы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Интерфейсы</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-4">
<FormField label="Полное имя" required error={errors.name}>
<Input value={form.name} onChange={(e) => setField("name", e.target.value)} placeholder="Иван Иванов" />
</FormField>
<FormField label="Email" required error={errors.email}>
<Input
value={form.email}
onChange={(e) => setField("email", e.target.value)}
placeholder="[email protected]"
type="email"
/>
</FormField>
<FormField label="Роль" required>
<SegmentedControl
value={form.role}
onChange={setRole}
options={[
{ value: "viewer", label: ROLE_LABEL.viewer },
{ value: "operator", label: ROLE_LABEL.operator },
{ value: "admin", label: ROLE_LABEL.admin },
]}
/>
</FormField>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Активна</span>
<FormToggle checked={form.active} onChange={(v) => setField("active", v)} />
</div>
</StepperContent>
<StepperContent value={2} className="flex flex-col">
<div className="flex items-center gap-2 px-0 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map((o) => (
<button
key={o.v}
type="button"
onClick={() => setAllSections(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}
>
{o.label}
</button>
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-muted-foreground flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
{SECTION_GROUP_DEFS.map((group) => (
<div key={group.group}>
<div className="flex items-center gap-2 py-1.5 bg-muted/20 border-b">
<span className="text-muted-foreground">{SECTION_GROUP_ICONS[group.group]}</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{group.group}
</span>
</div>
<div className="divide-y divide-border/60">
{group.items.map((section) => {
const perm = form.sections.find((s) => s.section === section)
const level = perm?.level ?? "none"
return (
<div key={section} className="flex items-center justify-between py-2.5 hover:bg-muted/20 transition-colors">
<span className="text-sm">{section}</span>
<PermPills value={level} onChange={(v) => setSectionPerm(section, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</div>
))}
</StepperContent>
<StepperContent value={3} className="flex flex-col">
<div className="flex items-center gap-2 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map((o) => (
<button
key={o.v}
type="button"
onClick={() => setAllServers(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}
>
{o.label}
</button>
))}
</div>
<div className="divide-y divide-border/60">
{servers.map((srv) => {
const perm = form.servers.find((s) => s.serverId === srv.id)
const level = perm?.level ?? "none"
return (
<div key={srv.id} className="flex items-center justify-between gap-3 py-2.5 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
{srv.status ? <StatusDot status={srv.status} /> : null}
{srv.country ? <Flag code={srv.country} size={14} /> : null}
<div className="min-w-0">
<p className="text-sm font-medium truncate leading-tight">{srv.name}</p>
<p className="text-[11px] font-mono text-muted-foreground">{srv.host} · {srv.site}</p>
</div>
</div>
<PermPills value={level} onChange={(v) => setServerPerm(srv.id, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</StepperContent>
<StepperContent value={4} className="flex flex-col">
<p className="text-[11px] text-muted-foreground py-2.5 border-b">
Привязка интерфейсов сервера. Один интерфейс один пользователь.
</p>
<div className="py-3 flex flex-col gap-3 border-b">
<label className="text-[10px] text-muted-foreground">Сервер</label>
<select
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
value={catalogServerId}
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedNames([]) }}
>
{servers.map((s) => (
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
))}
</select>
<div className="flex flex-col gap-1 rounded-md border border-input bg-background px-2 py-1.5 max-h-48 overflow-y-auto">
{catalog.length === 0 && (
<p className="text-[11px] text-muted-foreground py-1">Нет интерфейсов в каталоге</p>
)}
{catalog.map((iface) => {
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
const mine = alreadyBoundHere.has(iface.name)
const disabled = taken || mine
return (
<label
key={iface.name}
className={cn(
"flex items-center gap-2 py-0.5 text-xs",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
)}
>
<input
type="checkbox"
disabled={disabled}
checked={selectedNames.includes(iface.name)}
onChange={() => toggleName(iface.name)}
className="rounded border-input accent-primary"
/>
<span className="font-mono">{iface.name}</span>
<Badge variant={TYPE_VARIANT[iface.type as InterfaceType]} size="sm">
{IFACE_TYPE_LABEL[iface.type as InterfaceType]}
</Badge>
{taken && (
<span className="text-[10px] text-muted-foreground ml-auto">{iface.boundUserLogin}</span>
)}
{mine && !taken && (
<span className="text-[10px] text-muted-foreground ml-auto">уже привязан</span>
)}
</label>
)
})}
</div>
<Input
className="h-8 text-xs"
placeholder="Комментарий (необязательно)"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<Button size="sm" disabled={selectedNames.length === 0} onClick={addSelectedBindings}>
<PlusIcon className="size-3.5" />Привязать
</Button>
</div>
<div className="flex flex-col gap-1.5 py-3">
{form.bindings.length === 0 && (
<p className="text-xs text-muted-foreground">Нет привязанных интерфейсов</p>
)}
{form.bindings.map((b) => (
<div key={b.id} className="flex items-center gap-2 py-1">
<Flag code={b.serverCountry} size={12} />
<span className="font-mono text-xs truncate">{b.interfaceName}</span>
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">{IFACE_TYPE_LABEL[b.interfaceType]}</Badge>
<span className="text-[10px] text-muted-foreground truncate">{b.serverName}</span>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 ml-auto text-muted-foreground hover:text-destructive"
onClick={() => removeBinding(b.id)}
>
<TrashIcon className="size-3.5" />
</Button>
</div>
))}
</div>
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" disabled={saving} onClick={submit}>
{isCreate ? "Создать" : "Сохранить"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { UserSheet, type UserSheetProps }