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
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:
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user