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
@@ -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 }