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:
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle } from "@/components/form-kit"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
@@ -12,10 +12,6 @@ import { Input } from "@/components/ui/input"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP, type EvoBgpSavePayload, type EvoBgpTestDraft } from "@/lib/evobgp-context"
|
||||
import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -27,119 +23,19 @@ import {
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { servers } from "@/lib/data"
|
||||
import {
|
||||
SaveIcon, PencilIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
|
||||
XIcon, AlertCircleIcon, ShieldIcon, EyeIcon, EyeOffIcon, WrenchIcon, UserIcon,
|
||||
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
|
||||
DownloadIcon, UploadIcon,
|
||||
SaveIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
|
||||
AlertCircleIcon, EyeIcon, EyeOffIcon,
|
||||
RefreshCwIcon, LoaderCircleIcon,
|
||||
DownloadIcon, UploadIcon, XIcon,
|
||||
} from "lucide-react"
|
||||
import { SubusersDataGrid } from "@/components/data-grids/subusers-data-grid"
|
||||
import { SettingsAccessSummaryDataGrid } from "@/components/data-grids/settings-access-summary-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Role = "admin" | "operator" | "viewer"
|
||||
type PermLevel = "none" | "read" | "write"
|
||||
|
||||
interface SectionPerm { section: string; level: PermLevel }
|
||||
interface ServerPerm { serverId: string; level: PermLevel }
|
||||
|
||||
interface User {
|
||||
id: string; name: string; login: string; email: string
|
||||
role: Role; last: string; avatar: string; active: boolean
|
||||
sections: SectionPerm[]; servers: ServerPerm[]
|
||||
subUsers: SubUser[]
|
||||
}
|
||||
|
||||
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
|
||||
|
||||
// Подчинённый пользователь — учётка для подключения устройства к JH через GRE
|
||||
interface SubUser {
|
||||
id: string
|
||||
login: string
|
||||
password: string
|
||||
description: string // "офисный роутер", "склад", и т.п.
|
||||
jhServerIds: string[] // на каких JH зарегистрирована учётка
|
||||
clientIp: string // назначенный IP внутри туннеля
|
||||
active: boolean
|
||||
lastSeen: string | null
|
||||
}
|
||||
|
||||
// ─── section definitions ──────────────────────────────────────────────────────
|
||||
|
||||
const SECTION_GROUPS: { group: string; icon: React.ReactNode; items: string[] }[] = [
|
||||
{ group: "Обзор", icon: <LayoutDashboardIcon className="size-3" />, items: ["Дашборд", "Трафик", "Карта сети", "Мониторинг"] },
|
||||
{ group: "Данные", icon: <EyeIcon className="size-3" />, items: ["Домены", "IP-диапазоны", "ASN", "Communities"] },
|
||||
{ group: "Управление", icon: <WrenchIcon className="size-3" />, items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] },
|
||||
{ group: "Инструменты", icon: <ShieldIcon className="size-3" />, items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] },
|
||||
{ group: "Система", icon: <ServerIcon className="size-3" />, items: ["Оповещения", "Сбор данных", "Настройки"] },
|
||||
]
|
||||
|
||||
const ALL_SECTIONS = SECTION_GROUPS.flatMap(g => g.items)
|
||||
|
||||
// ─── default permissions by role ──────────────────────────────────────────────
|
||||
|
||||
function defaultSections(role: Role): SectionPerm[] {
|
||||
return ALL_SECTIONS.map(section => {
|
||||
let level: PermLevel = "none"
|
||||
if (role === "admin") level = "write"
|
||||
else if (role === "operator") level = ["Серверы","Фильтры","Firewall","GRE-туннели","Бэкапы","Диагностика GRE","Оптимизатор маршрутов","OSPF"].includes(section) ? "write" : "read"
|
||||
else if (role === "viewer") level = ["Настройки","Терминал"].includes(section) ? "none" : "read"
|
||||
return { section, level }
|
||||
})
|
||||
}
|
||||
|
||||
function defaultServers(role: Role): ServerPerm[] {
|
||||
return servers.map(s => ({
|
||||
serverId: s.id,
|
||||
level: role === "admin" ? "write" : role === "operator" ? "read" : "read" as PermLevel,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── initial data ──────────────────────────────────────────────────────────────
|
||||
|
||||
const INIT_USERS: User[] = [
|
||||
{
|
||||
id: "u1", name: "Александр Коротаев", login: "a.korotaev", email: "[email protected]",
|
||||
role: "admin", last: "сейчас", avatar: "АК", active: true,
|
||||
sections: defaultSections("admin"), servers: defaultServers("admin"),
|
||||
subUsers: [
|
||||
{ id: "su1", login: "gre-office-msk", password: "xK9#mQ2$vLp8", description: "Офис MSK", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.2", active: true, lastSeen: "5м назад" },
|
||||
{ id: "su2", login: "gre-datacenter", password: "pW3@jT7!hD5k", description: "ЦОД Tier-2", jhServerIds: ["srv1"], clientIp: "10.210.0.6", active: true, lastSeen: "1ч назад" },
|
||||
{ id: "su3", login: "gre-warehouse", password: "rN6%bF2*cM8s", description: "Склад Химки", jhServerIds: ["srv7"], clientIp: "10.210.1.2", active: false, lastSeen: "3 дн назад" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "u2", name: "Дмитрий Фёдоров", login: "d.fedorov", email: "[email protected]",
|
||||
role: "operator", last: "2ч назад", avatar: "ДФ", active: true,
|
||||
sections: defaultSections("operator"), servers: defaultServers("operator"),
|
||||
subUsers: [
|
||||
{ id: "su4", login: "gre-spb-branch", password: "qA4^eU9#wG1o", description: "Филиал SPB", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.10", active: true, lastSeen: "20м назад" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "u3", name: "Мария Соколова", login: "m.sokolova", email: "[email protected]",
|
||||
role: "viewer", last: "вчера", avatar: "МС", active: true,
|
||||
sections: defaultSections("viewer"), servers: defaultServers("viewer"),
|
||||
subUsers: [],
|
||||
},
|
||||
{
|
||||
id: "u4", name: "Игорь Петров", login: "i.petrov", email: "[email protected]",
|
||||
role: "operator", last: "3 дн назад", avatar: "ИП", active: false,
|
||||
sections: defaultSections("operator"), servers: defaultServers("operator"),
|
||||
subUsers: [
|
||||
{ id: "su5", login: "gre-retail-01", password: "bF7!nK3@mZ9v", description: "Магазин #1", jhServerIds: ["srv1"], clientIp: "10.210.0.14", active: false, lastSeen: null },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const INIT_API_KEYS: ApiKey[] = [
|
||||
{ id: "k1", name: "Monitoring Script", prefix: "rl_live_mU9k2…", created: "12 дн назад", last: "2м назад", scopes: ["servers:read", "probes:read"] },
|
||||
@@ -147,26 +43,7 @@ const INIT_API_KEYS: ApiKey[] = [
|
||||
{ id: "k3", name: "Grafana datasource", prefix: "rl_live_xK7r1…", created: "2 мес назад", last: "сейчас", scopes: ["traffic:read", "servers:read"] },
|
||||
]
|
||||
|
||||
const ROLE_LABEL: Record<Role, string> = { admin: "Администратор", operator: "Оператор", viewer: "Наблюдатель" }
|
||||
const ROLE_COLOR: Record<Role, string> = {
|
||||
admin: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20",
|
||||
operator: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border border-sky-500/20",
|
||||
viewer: "bg-muted text-muted-foreground border border-border",
|
||||
}
|
||||
|
||||
const PERM_OPTS: { v: PermLevel; label: string }[] = [
|
||||
{ v: "none", label: "Нет" },
|
||||
{ v: "read", label: "Просмотр" },
|
||||
{ v: "write", label: "Управление"},
|
||||
]
|
||||
|
||||
const PERM_COLOR: Record<PermLevel, string> = {
|
||||
none: "bg-muted text-muted-foreground",
|
||||
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
write: "bg-success/10 text-success",
|
||||
}
|
||||
|
||||
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
|
||||
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const
|
||||
type NavSection = typeof SECTIONS_NAV[number]
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
@@ -175,8 +52,6 @@ function makeApiFetch(backendUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── small components ─────────────────────────────────────────────────────────
|
||||
|
||||
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-3.5">
|
||||
@@ -189,512 +64,6 @@ function SettingRow({ label, description, children }: { label: string; descripti
|
||||
)
|
||||
}
|
||||
|
||||
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 AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<div className={cn(
|
||||
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold",
|
||||
"bg-gradient-to-br from-blue-500 to-violet-500",
|
||||
!active && "opacity-50",
|
||||
)}>{avatar}</div>
|
||||
{!active && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full bg-muted-foreground/50 border-2 border-background" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── user sheet ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface UserForm {
|
||||
name: string; login: string; email: string; role: Role; active: boolean
|
||||
sections: SectionPerm[]; servers: ServerPerm[]
|
||||
subUsers: SubUser[]
|
||||
}
|
||||
|
||||
function emptyForm(): UserForm {
|
||||
return { name: "", login: "", email: "", role: "viewer", active: true,
|
||||
sections: defaultSections("viewer"), servers: defaultServers("viewer"), subUsers: [] }
|
||||
}
|
||||
|
||||
function genPassword(): string {
|
||||
const chars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!@#$%"
|
||||
return Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join("")
|
||||
}
|
||||
|
||||
function genSubLogin(userLogin: string, existing: SubUser[]): string {
|
||||
const base = `gre-${userLogin.split(".").pop() ?? "client"}`
|
||||
const n = existing.filter(s => s.login.startsWith(base)).length
|
||||
return n === 0 ? base : `${base}-${String(n + 1).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function UserSheet({ open, user, onSave, onClose }: {
|
||||
open: boolean
|
||||
user: User | null // null = create
|
||||
onSave: (f: UserForm) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const isCreate = user === null
|
||||
const [tab, setTab] = useState<"profile" | "sections" | "servers" | "subusers">("profile")
|
||||
const [form, setForm] = useState<UserForm>(() =>
|
||||
user ? {
|
||||
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 })),
|
||||
subUsers: user.subUsers.map(s => ({ ...s })),
|
||||
} : emptyForm()
|
||||
)
|
||||
// sub-user add form state
|
||||
const [addSubOpen, setAddSubOpen] = useState(false)
|
||||
const [newSubLogin, setNewSubLogin] = useState("")
|
||||
const [newSubPwd, setNewSubPwd] = useState("")
|
||||
const [newSubDesc, setNewSubDesc] = useState("")
|
||||
const [newSubJhs, setNewSubJhs] = useState<string[]>([])
|
||||
const [newSubIp, setNewSubIp] = useState("")
|
||||
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set())
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof UserForm, string>>>({})
|
||||
|
||||
const setField = <K extends keyof UserForm>(k: K, v: UserForm[K]) => {
|
||||
setForm(f => ({ ...f, [k]: v }))
|
||||
if (errors[k]) setErrors(e => ({ ...e, [k]: undefined }))
|
||||
}
|
||||
|
||||
// When role changes → reset permissions to defaults
|
||||
const setRole = (role: Role) => {
|
||||
setForm(f => ({ ...f, role, sections: defaultSections(role), servers: defaultServers(role) }))
|
||||
}
|
||||
|
||||
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 validate = () => {
|
||||
const e: Partial<Record<keyof UserForm, string>> = {}
|
||||
if (!form.name.trim()) e.name = "Обязательное поле"
|
||||
if (!form.login.trim()) e.login = "Обязательное поле"
|
||||
if (!form.email.trim()) e.email = "Обязательное поле"
|
||||
setErrors(e)
|
||||
return !Object.keys(e).length
|
||||
}
|
||||
|
||||
const handleSave = () => { if (validate()) onSave(form) }
|
||||
|
||||
const isAdmin = form.role === "admin"
|
||||
|
||||
const jhServers = servers.filter(s => s.type === "jump-host" && s.enabled)
|
||||
|
||||
const addSubUser = () => {
|
||||
if (!newSubLogin.trim() || !newSubPwd.trim() || newSubJhs.length === 0) return
|
||||
const sub: SubUser = {
|
||||
id: `su${Date.now()}`,
|
||||
login: newSubLogin.trim(),
|
||||
password: newSubPwd,
|
||||
description: newSubDesc.trim(),
|
||||
jhServerIds: [...newSubJhs],
|
||||
clientIp: newSubIp.trim(),
|
||||
active: true,
|
||||
lastSeen: null,
|
||||
}
|
||||
setForm(f => ({ ...f, subUsers: [...f.subUsers, sub] }))
|
||||
setAddSubOpen(false)
|
||||
setNewSubLogin(""); setNewSubPwd(""); setNewSubDesc(""); setNewSubIp(""); setNewSubJhs([])
|
||||
}
|
||||
|
||||
const toggleNewSubJh = (id: string) =>
|
||||
setNewSubJhs(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||||
|
||||
const removeSubUser = (id: string) =>
|
||||
setForm(f => ({ ...f, subUsers: f.subUsers.filter(s => s.id !== id) }))
|
||||
|
||||
const toggleSubUser = (id: string) =>
|
||||
setForm(f => ({ ...f, subUsers: f.subUsers.map(s => s.id === id ? { ...s, active: !s.active } : s) }))
|
||||
|
||||
const toggleReveal = (id: string) =>
|
||||
setRevealedIds(prev => {
|
||||
const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next
|
||||
})
|
||||
|
||||
const TABS = [
|
||||
{ id: "profile", label: "Профиль" },
|
||||
{ id: "sections", label: "Разделы" },
|
||||
{ id: "servers", label: "Серверы" },
|
||||
{ id: "subusers", label: "GRE-клиенты", badge: form.subUsers.length || undefined },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||||
<SheetContent side="right" className="sm:max-w-[520px] p-0 flex flex-col" showCloseButton={false}>
|
||||
|
||||
{/* header */}
|
||||
<SheetHeader className="px-5 pt-5 pb-0 border-b shrink-0">
|
||||
<div className="flex items-start justify-between gap-2 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{!isCreate && user && (
|
||||
<AvatarCircle avatar={user.avatar} active={form.active} />
|
||||
)}
|
||||
<div>
|
||||
<SheetTitle className="text-base leading-tight">
|
||||
{isCreate ? "Новый пользователь" : form.name || "—"}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-xs mt-0.5">
|
||||
{isCreate ? "Заполните данные и настройте доступ" : `@${form.login}`}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5">
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* tab bar */}
|
||||
<div className="flex gap-0 -mb-px">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id as typeof tab)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5",
|
||||
tab === t.id
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{t.label}
|
||||
{"badge" in t && t.badge !== undefined && (
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full tabular-nums",
|
||||
tab === t.id
|
||||
? "bg-foreground/10"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>{t.badge}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
{/* ── Profile tab ── */}
|
||||
{tab === "profile" && (
|
||||
<div className="px-5 py-5 flex flex-col gap-4">
|
||||
|
||||
<FormField label="Полное имя" error={errors.name}>
|
||||
<Input value={form.name} onChange={e => setField("name", e.target.value)}
|
||||
placeholder="Иван Иванов" className="h-9" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Логин" error={errors.login}>
|
||||
<Input value={form.login} onChange={e => setField("login", e.target.value)}
|
||||
placeholder="i.ivanov" className="h-9 font-mono" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Email" error={errors.email}>
|
||||
<Input value={form.email} onChange={e => setField("email", e.target.value)}
|
||||
placeholder="[email protected]" type="email" className="h-9" />
|
||||
</FormField>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FormField label="Роль">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(["viewer", "operator", "admin"] as Role[]).map(r => (
|
||||
<button key={r} type="button" onClick={() => setRole(r)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-md border text-xs font-medium transition-all",
|
||||
form.role === r
|
||||
? r === "admin" ? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/30 ring-1 ring-violet-500/30"
|
||||
: r === "operator" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/30 ring-1 ring-sky-500/30"
|
||||
: "bg-muted text-foreground border-border ring-1 ring-border"
|
||||
: "border-border text-muted-foreground hover:bg-muted",
|
||||
)}>
|
||||
{ROLE_LABEL[r]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||
{form.role === "admin"
|
||||
? "Полный доступ ко всем разделам и серверам, не ограничивается матрицей прав"
|
||||
: form.role === "operator"
|
||||
? "Управление инфраструктурой согласно выданным правам"
|
||||
: "Только просмотр согласно выданным правам"}
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FormField label="Статус учётной записи">
|
||||
<div className="flex items-center gap-3">
|
||||
<FormToggle checked={form.active} onChange={v => setField("active", v)} />
|
||||
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
|
||||
{form.active ? "Активна" : "Заблокирована"}
|
||||
</span>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Sections tab ── */}
|
||||
{tab === "sections" && (
|
||||
<div className="flex flex-col">
|
||||
{/* bulk actions */}
|
||||
<div className="flex items-center gap-2 px-4 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-violet-600 dark:text-violet-400 flex items-center gap-1">
|
||||
<ShieldIcon className="size-3" />Администратор имеет полный доступ
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{SECTION_GROUPS.map(group => (
|
||||
<div key={group.group}>
|
||||
{/* group header */}
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-muted/20 border-b">
|
||||
<span className="text-muted-foreground">{group.icon}</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{group.group}
|
||||
</span>
|
||||
</div>
|
||||
{/* section rows */}
|
||||
<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 px-4 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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Servers tab ── */}
|
||||
{tab === "servers" && (
|
||||
<div className="flex flex-col">
|
||||
{/* bulk actions */}
|
||||
<div className="flex items-center gap-2 px-4 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>
|
||||
))}
|
||||
{isAdmin && (
|
||||
<span className="ml-auto text-[11px] text-violet-600 dark:text-violet-400 flex items-center gap-1">
|
||||
<ShieldIcon className="size-3" />Администратор имеет полный доступ
|
||||
</span>
|
||||
)}
|
||||
</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 px-4 py-2.5 hover:bg-muted/20 transition-colors">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<StatusDot status={srv.status} />
|
||||
{srv.country && <Flag code={srv.country} size={14} />}
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── GRE-клиенты tab ── */}
|
||||
{tab === "subusers" && (
|
||||
<div className="flex flex-col">
|
||||
|
||||
{/* hint */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-muted/30">
|
||||
<CableIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Учётные записи для подключения устройств к JH через GRE
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SubusersDataGrid
|
||||
subUsers={form.subUsers}
|
||||
servers={servers}
|
||||
revealedIds={revealedIds}
|
||||
onToggleReveal={toggleReveal}
|
||||
onToggleActive={toggleSubUser}
|
||||
onRemove={removeSubUser}
|
||||
/>
|
||||
|
||||
{/* inline add form */}
|
||||
{addSubOpen ? (
|
||||
<div className="border-t bg-muted/20 px-4 py-4 flex flex-col gap-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">Новый GRE-клиент</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-muted-foreground">Логин</label>
|
||||
<Input className="h-8 text-xs font-mono" placeholder="gre-office-msk"
|
||||
value={newSubLogin} onChange={e => setNewSubLogin(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-muted-foreground">Описание</label>
|
||||
<Input className="h-8 text-xs" placeholder="Офис MSK"
|
||||
value={newSubDesc} onChange={e => setNewSubDesc(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-muted-foreground">Пароль</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Input className="h-8 text-xs font-mono flex-1" placeholder="••••••••"
|
||||
value={newSubPwd} onChange={e => setNewSubPwd(e.target.value)} />
|
||||
<Button variant="outline" size="sm" className="h-8 px-2 shrink-0"
|
||||
onClick={() => setNewSubPwd(genPassword())} title="Сгенерировать пароль">
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-muted-foreground">
|
||||
JH-серверы
|
||||
<span className="ml-1 text-muted-foreground/50">(можно выбрать несколько)</span>
|
||||
</label>
|
||||
<div className="flex flex-col gap-1 rounded-md border border-input bg-background px-2 py-1.5">
|
||||
{jhServers.map(s => {
|
||||
const checked = newSubJhs.includes(s.id)
|
||||
return (
|
||||
<label key={s.id}
|
||||
className="flex items-center gap-2 py-0.5 cursor-pointer hover:text-foreground transition-colors">
|
||||
<input type="checkbox" checked={checked}
|
||||
onChange={() => toggleNewSubJh(s.id)}
|
||||
className="rounded border-input accent-primary" />
|
||||
<Flag code={s.country} size={12} />
|
||||
<span className="font-mono text-xs">{s.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{s.site}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{newSubJhs.length === 0 && (
|
||||
<p className="text-[10px] text-destructive">Выберите хотя бы один JH-сервер</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-muted-foreground">IP-клиента</label>
|
||||
<Input className="h-8 text-xs font-mono" placeholder="10.210.0.18"
|
||||
value={newSubIp} onChange={e => setNewSubIp(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" className="flex-1"
|
||||
onClick={() => { setAddSubOpen(false); setNewSubLogin(""); setNewSubPwd(""); setNewSubDesc(""); setNewSubIp(""); setNewSubJhs([]) }}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1"
|
||||
disabled={!newSubLogin.trim() || !newSubPwd.trim() || newSubJhs.length === 0}
|
||||
onClick={addSubUser}>
|
||||
<PlusIcon className="size-3.5" />Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewSubLogin(genSubLogin(form.login, form.subUsers))
|
||||
setNewSubPwd(genPassword())
|
||||
setNewSubJhs(jhServers.length > 0 ? [jhServers[0].id] : [])
|
||||
setAddSubOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||
<PlusIcon className="size-3.5" />Добавить GRE-клиента
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
|
||||
<Button onClick={handleSave} className="flex-1">
|
||||
<CheckIcon className="size-4" />
|
||||
{isCreate ? "Создать" : "Сохранить"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── delete confirm ───────────────────────────────────────────────────────────
|
||||
|
||||
function DatabaseRestoreConfirm({
|
||||
open,
|
||||
filename,
|
||||
@@ -737,41 +106,6 @@ function DatabaseRestoreConfirm({
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteConfirm({
|
||||
open,
|
||||
user,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean
|
||||
user: User
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<TrashIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{user.name} · @{user.login}. Это действие нельзя отменить.
|
||||
Пользователь потеряет доступ немедленно.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -824,12 +158,6 @@ export default function SettingsPage() {
|
||||
const [notifBgp, setNotifBgp] = useState(true)
|
||||
const [notifBackup, setNotifBackup] = useState(false)
|
||||
|
||||
// users
|
||||
const [users, setUsers] = useState<User[]>(INIT_USERS)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editUser, setEditUser] = useState<User | null>(null) // null = create mode
|
||||
const [deleteTarget, setDeleteTarget]= useState<User | null>(null)
|
||||
|
||||
// api keys
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>(INIT_API_KEYS)
|
||||
|
||||
@@ -851,22 +179,6 @@ export default function SettingsPage() {
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}, [])
|
||||
|
||||
const handleUserSave = (form: UserForm) => {
|
||||
if (!editUser) {
|
||||
const initials = form.name.split(" ").map(p => p[0] ?? "").slice(0, 2).join("").toUpperCase()
|
||||
setUsers(prev => [...prev, {
|
||||
id: "u" + Date.now(), ...form,
|
||||
last: "только что", avatar: initials || "??",
|
||||
}])
|
||||
} else {
|
||||
setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form } : u))
|
||||
}
|
||||
setSheetOpen(false)
|
||||
}
|
||||
|
||||
// total sub-users count for summary
|
||||
const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "EvoBGP") return
|
||||
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
|
||||
@@ -1409,94 +721,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── Пользователи ──
|
||||
if (section === "Пользователи") return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{users.length} пользователей · {users.filter(u => u.active).length} активных
|
||||
{totalSubUsers > 0 && ` · ${totalSubUsers} GRE-клиентов`}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Пригласить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Frame dense className="w-full">
|
||||
<FramePanel className="overflow-hidden p-0">
|
||||
{/* table header */}
|
||||
<div className="grid grid-cols-[1fr_120px_100px_80px_auto] items-center gap-3 px-4 py-2 border-b bg-muted/30 text-[11px] font-medium text-muted-foreground">
|
||||
<span>Пользователь</span>
|
||||
<span>Роль</span>
|
||||
<span>Последний вход</span>
|
||||
<span>Статус</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/60">
|
||||
{users.map(u => {
|
||||
return (
|
||||
<div key={u.id} className="grid grid-cols-[1fr_120px_100px_80px_auto] items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors">
|
||||
{/* user */}
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<AvatarCircle avatar={u.avatar} active={u.active} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{u.name}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<p className="text-[11px] font-mono text-muted-foreground">@{u.login}</p>
|
||||
{u.subUsers.length > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-muted-foreground/60">
|
||||
<CableIcon className="size-2.5" />{u.subUsers.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* role */}
|
||||
<span className={cn("text-[11px] font-medium px-2 py-0.5 rounded-full w-fit", ROLE_COLOR[u.role])}>
|
||||
{ROLE_LABEL[u.role]}
|
||||
</span>
|
||||
{/* last */}
|
||||
<span className="text-xs text-muted-foreground">{u.last}</span>
|
||||
{/* status */}
|
||||
<span className={cn("text-[11px] font-medium", u.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
|
||||
{u.active ? "Активен" : "Заблокирован"}
|
||||
</span>
|
||||
{/* actions */}
|
||||
<div className="flex items-center gap-0.5 justify-end">
|
||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => { setEditUser(u); setSheetOpen(true) }} title="Редактировать">
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
{u.id !== "u1" && (
|
||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(u)} title="Удалить">
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
{/* access summary */}
|
||||
<DataPageCard>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<UserIcon className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium">Сводка прав доступа</span>
|
||||
</div>
|
||||
<SettingsAccessSummaryDataGrid
|
||||
users={users}
|
||||
servers={servers}
|
||||
allSectionsCount={ALL_SECTIONS.length}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── API-ключи ──
|
||||
if (section === "API-ключи") return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -1655,15 +879,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* user sheet */}
|
||||
<UserSheet
|
||||
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||||
open={sheetOpen}
|
||||
user={editUser}
|
||||
onSave={handleUserSave}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
/>
|
||||
|
||||
{/* delete confirm */}
|
||||
{dbRestoreFile && (
|
||||
<DatabaseRestoreConfirm
|
||||
@@ -1677,14 +892,6 @@ export default function SettingsPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{deleteTarget && (
|
||||
<DeleteConfirm
|
||||
open={!!deleteTarget}
|
||||
user={deleteTarget}
|
||||
onConfirm={() => { setUsers(p => p.filter(u => u.id !== deleteTarget.id)); setDeleteTarget(null) }}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FileImportDialog
|
||||
open={dbRestoreDialogOpen}
|
||||
@@ -1701,3 +908,4 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user