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