mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-02-04 04:30:06 +00:00
User Role (#69)
* fix: window.DisableAnimatedMan as boolean * chore: auto-fix linting and formatting issues * feat: user role * feat: use user agent_secret * feat: hide setting when user role is not admin * feat: new waf api * chore: auto-fix linting and formatting issues * fix: admin settings page * feat: online-user setting * fix: pagination --------- Co-authored-by: hamster1963 <hamster1963@users.noreply.github.com>
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.1",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
|
||||
|
||||
5
src/api/online-user.ts
Normal file
5
src/api/online-user.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { FetcherMethod, fetcher } from "./api"
|
||||
|
||||
export const blockUser = async (ip: string[]): Promise<void> => {
|
||||
return fetcher<void>(FetcherMethod.POST, "/api/v1/online-user/batch-block", ip)
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
import useSettings from "@/hooks/useSetting"
|
||||
import { copyToClipboard } from "@/lib/utils"
|
||||
import { ModelSettingResponse } from "@/types"
|
||||
import { ModelProfile, ModelSettingResponse } from "@/types"
|
||||
import i18next from "i18next"
|
||||
import { Check, Clipboard } from "lucide-react"
|
||||
import { forwardRef, useState } from "react"
|
||||
@@ -23,14 +24,17 @@ enum OSTypes {
|
||||
export const InstallCommandsMenu = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
||||
const [copy, setCopy] = useState(false)
|
||||
const { data: settings } = useSettings()
|
||||
const { profile } = useAuth()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const switchState = async (type: number) => {
|
||||
if (!copy) {
|
||||
try {
|
||||
setCopy(true)
|
||||
if (!profile) throw new Error("Profile is not found.")
|
||||
if (!settings) throw new Error("Settings is not found.")
|
||||
await copyToClipboard(generateCommand(type, settings) || "")
|
||||
await copyToClipboard(generateCommand(type, settings, profile) || "")
|
||||
} catch (e: Error | any) {
|
||||
console.error(e)
|
||||
toast(t("Error"), {
|
||||
@@ -85,9 +89,19 @@ export const InstallCommandsMenu = forwardRef<HTMLButtonElement, ButtonProps>((p
|
||||
const generateCommand = (
|
||||
type: number,
|
||||
{ agent_secret_key, install_host, tls }: ModelSettingResponse,
|
||||
{ agent_secret, role }: ModelProfile,
|
||||
) => {
|
||||
if (!install_host) throw new Error(i18next.t("Results.InstallHostRequired"))
|
||||
|
||||
// 如果 agent_secret 为空且 role 为 0 ,则使用 agent_secret_key,否则如果 agent_secret 为空则报错
|
||||
if (!agent_secret && role === 0) {
|
||||
agent_secret = agent_secret_key
|
||||
} else if (!agent_secret) {
|
||||
throw new Error(i18next.t("Results.AgentSecretRequired"))
|
||||
}
|
||||
|
||||
agent_secret_key = agent_secret
|
||||
|
||||
const env = `NZ_SERVER=${install_host} NZ_TLS=${tls || false} NZ_CLIENT_SECRET=${agent_secret_key}`
|
||||
const env_win = `$env:NZ_SERVER=\"${install_host}\";$env:NZ_TLS=\"${tls || false}\";$env:NZ_CLIENT_SECRET=\"${agent_secret_key}\";`
|
||||
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, useLocation } from "react-router-dom"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export const SettingsTab = ({ className }: { className?: string }) => {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const { profile } = useAuth()
|
||||
|
||||
const isAdmin = profile?.role === 0
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={location.pathname} className={className}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="/dashboard/settings" asChild>
|
||||
<Link to="/dashboard/settings">{t("Settings")}</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="/dashboard/settings/user" asChild>
|
||||
<Link to="/dashboard/settings/user">{t("User")}</Link>
|
||||
</TabsTrigger>
|
||||
<Tabs defaultValue={window.location.pathname} className={className}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<TabsTrigger value="/dashboard/settings" asChild>
|
||||
<Link to="/dashboard/settings">{t("Settings")}</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="/dashboard/settings/user" asChild>
|
||||
<Link to="/dashboard/settings/user">{t("User")}</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="/dashboard/settings/online-user" asChild>
|
||||
<Link to="/dashboard/settings/online-user">{t("OnlineUser")}</Link>
|
||||
</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
<TabsTrigger value="/dashboard/settings/waf" asChild>
|
||||
<Link to="/dashboard/settings/waf">{t("WAF")}</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
97
src/components/ui/pagination.tsx
Normal file
97
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
|
||||
),
|
||||
)
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
|
||||
({ className, ...props }, ref) => <li ref={ref} className={cn("", className)} {...props} />,
|
||||
)
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
@@ -20,6 +20,13 @@ import {
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { IconButton } from "@/components/xui/icon-button"
|
||||
import { ModelUser } from "@/types"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
@@ -35,6 +42,7 @@ interface UserCardProps {
|
||||
|
||||
const userFormSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
role: z.number().int().min(0).max(1),
|
||||
password: z.string().min(8).max(72),
|
||||
})
|
||||
|
||||
@@ -44,6 +52,7 @@ export const UserCard: React.FC<UserCardProps> = ({ mutate }) => {
|
||||
resolver: zodResolver(userFormSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
role: 1,
|
||||
password: "",
|
||||
},
|
||||
resetOptions: {
|
||||
@@ -100,6 +109,34 @@ export const UserCard: React.FC<UserCardProps> = ({ mutate }) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Role")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange(parseInt(value))
|
||||
}
|
||||
defaultValue={field.value.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("SelectRole")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{t("Admin")}</SelectItem>
|
||||
<SelectItem value="1">{t("User")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="justify-end">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" className="my-2" variant="secondary">
|
||||
|
||||
@@ -20,6 +20,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
;(async () => {
|
||||
try {
|
||||
const user = await getProfile()
|
||||
user.role = user.role || 0
|
||||
setProfile(user)
|
||||
} catch (error: any) {
|
||||
setProfile(undefined)
|
||||
|
||||
@@ -15,6 +15,7 @@ import LoginPage from "./routes/login"
|
||||
import NATPage from "./routes/nat"
|
||||
import NotificationPage from "./routes/notification"
|
||||
import NotificationGroupPage from "./routes/notification-group"
|
||||
import OnlineUserPage from "./routes/online-user"
|
||||
import ProfilePage from "./routes/profile"
|
||||
import ProtectedRoute from "./routes/protect"
|
||||
import Root from "./routes/root"
|
||||
@@ -133,6 +134,10 @@ const router = createBrowserRouter([
|
||||
path: "/dashboard/settings/waf",
|
||||
element: <WAFPage />,
|
||||
},
|
||||
{
|
||||
path: "/dashboard/settings/online-user",
|
||||
element: <OnlineUserPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
305
src/routes/online-user.tsx
Normal file
305
src/routes/online-user.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { swrFetcher } from "@/api/api"
|
||||
import { blockUser } from "@/api/online-user"
|
||||
import { ActionButtonGroup } from "@/components/action-button-group"
|
||||
import { HeaderButtonGroup } from "@/components/header-button-group"
|
||||
import { SettingsTab } from "@/components/settings-tab"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
import { ModelOnlineUser, ModelOnlineUserApi } from "@/types"
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import useSWR from "swr"
|
||||
|
||||
export default function OnlineUserPage() {
|
||||
const { t } = useTranslation()
|
||||
const { profile } = useAuth()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const page = Number(searchParams.get("page")) || 1
|
||||
const pageSize = Number(searchParams.get("pageSize")) || 10
|
||||
|
||||
// 计算 offset
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const { data, mutate, error, isLoading } = useSWR<ModelOnlineUserApi>(
|
||||
`/api/v1/online-user?offset=${offset}&limit=${pageSize}`,
|
||||
swrFetcher,
|
||||
)
|
||||
|
||||
const isAdmin = profile?.role === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (error)
|
||||
toast(t("Error"), {
|
||||
description: t(`Error fetching resource: ${error.message}.`),
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [error])
|
||||
|
||||
let columns: ColumnDef<ModelOnlineUser>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
header: "IP",
|
||||
accessorKey: "ip",
|
||||
accessorFn: (row) => row.ip ?? "",
|
||||
},
|
||||
{
|
||||
header: t("UserId"),
|
||||
accessorKey: "user_id",
|
||||
accessorFn: (row) => row.user_id || "",
|
||||
},
|
||||
{
|
||||
header: t("ConnectedAt"),
|
||||
accessorKey: "connected_at",
|
||||
accessorFn: (row) => row.connected_at,
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
const date = new Date(s.connected_at)
|
||||
return <span>{date.toISOString()}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
return (
|
||||
<ActionButtonGroup
|
||||
className="flex gap-2"
|
||||
delete={{
|
||||
fn: blockUser,
|
||||
id: s.ip ?? "",
|
||||
mutate: mutate,
|
||||
}}
|
||||
>
|
||||
<></>
|
||||
</ActionButtonGroup>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (!isAdmin) {
|
||||
// 非管理员隐藏操作列
|
||||
columns = columns.filter((c) => c.id !== "actions")
|
||||
}
|
||||
|
||||
const dataCache = useMemo(() => {
|
||||
return data?.value ?? []
|
||||
}, [data])
|
||||
|
||||
const table = useReactTable({
|
||||
data: dataCache,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
const selectedRows = table.getSelectedRowModel().rows
|
||||
|
||||
const renderPagination = () => {
|
||||
if (!data?.pagination) return null
|
||||
|
||||
const { total } = data.pagination
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage < 1 || newPage > totalPages) return
|
||||
setSearchParams({ page: newPage.toString(), pageSize: pageSize.toString() })
|
||||
}
|
||||
|
||||
// 计算要显示的页码范围
|
||||
const getPageNumbers = () => {
|
||||
const pages: number[] = []
|
||||
const maxVisiblePages = 5
|
||||
|
||||
if (totalPages <= maxVisiblePages) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
// 始终显示第一页
|
||||
pages.push(1)
|
||||
|
||||
let startPage = Math.max(2, page - 1)
|
||||
let endPage = Math.min(totalPages - 1, page + 1)
|
||||
|
||||
if (page <= 3) {
|
||||
endPage = Math.min(maxVisiblePages - 1, totalPages - 1)
|
||||
} else if (page >= totalPages - 2) {
|
||||
startPage = Math.max(2, totalPages - (maxVisiblePages - 2))
|
||||
}
|
||||
|
||||
if (startPage > 2) {
|
||||
pages.push(-1) // 表示省略号
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (endPage < totalPages - 1) {
|
||||
pages.push(-1) // 表示省略号
|
||||
}
|
||||
|
||||
// 始终显示最后一页
|
||||
if (totalPages > 1) {
|
||||
pages.push(totalPages)
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 py-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("Total")}: {total}
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
className={
|
||||
page <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPageNumbers().map((pageNum, idx) =>
|
||||
pageNum === -1 ? (
|
||||
<PaginationItem key={`ellipsis-${idx}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
isActive={pageNum === page}
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
className={
|
||||
page >= totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<SettingsTab className="mt-6 w-full" />
|
||||
<div className="flex mt-4 mb-4">
|
||||
{isAdmin && (
|
||||
<HeaderButtonGroup
|
||||
className="flex-2 flex gap-2 ml-auto"
|
||||
delete={{
|
||||
fn: blockUser,
|
||||
id: selectedRows.map((r) => r.original.ip ?? ""),
|
||||
mutate: mutate,
|
||||
}}
|
||||
>
|
||||
<></>
|
||||
</HeaderButtonGroup>
|
||||
)}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="text-sm">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{t("Loading")}...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="text-xsm">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{t("NoResults")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{renderPagination()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
import useSetting from "@/hooks/useSetting"
|
||||
import { asOptionalField } from "@/lib/utils"
|
||||
import { nezhaLang, settingCoverageTypes } from "@/types"
|
||||
@@ -28,6 +29,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
@@ -52,6 +54,17 @@ const settingFormSchema = z.object({
|
||||
export default function SettingsPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { data: config, mutate } = useSetting()
|
||||
const { profile } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const isAdmin = profile?.role === 0
|
||||
|
||||
console.log(isAdmin)
|
||||
|
||||
if (!isAdmin) {
|
||||
console.log("redirect")
|
||||
navigate("/dashboard/settings/waf")
|
||||
}
|
||||
|
||||
const form = useForm<z.infer<typeof settingFormSchema>>({
|
||||
resolver: zodResolver(settingFormSchema),
|
||||
@@ -62,7 +75,7 @@ export default function SettingsPage() {
|
||||
site_name: config.site_name || "",
|
||||
user_template:
|
||||
config.user_template ||
|
||||
Object.keys(config.frontend_templates.filter((t) => !t.is_admin) || {})[0] ||
|
||||
Object.keys(config.frontend_templates?.filter((t) => !t.is_admin) || {})[0] ||
|
||||
"user-dist",
|
||||
}
|
||||
: {
|
||||
@@ -173,7 +186,7 @@ export default function SettingsPage() {
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{(
|
||||
config?.frontend_templates.filter(
|
||||
config?.frontend_templates?.filter(
|
||||
(t) => !t.is_admin,
|
||||
) || []
|
||||
).map((template) => (
|
||||
|
||||
@@ -67,6 +67,13 @@ export default function UserPage() {
|
||||
accessorKey: "username",
|
||||
accessorFn: (row) => row.username,
|
||||
},
|
||||
{
|
||||
header: t("Role"),
|
||||
accessorKey: "role",
|
||||
accessorFn: (row) => {
|
||||
return row.role === 1 ? t("User") : t("Admin")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: t("Actions"),
|
||||
|
||||
@@ -4,6 +4,15 @@ import { ActionButtonGroup } from "@/components/action-button-group"
|
||||
import { HeaderButtonGroup } from "@/components/header-button-group"
|
||||
import { SettingsTab } from "@/components/settings-tab"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -12,17 +21,32 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
import { ip16Str } from "@/lib/utils"
|
||||
import { ModelWAFApiMock, wafBlockReasons } from "@/types"
|
||||
import { ModelWAF, ModelWAFApiMock, wafBlockIdentifiers, wafBlockReasons } from "@/types"
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import useSWR from "swr"
|
||||
|
||||
export default function WAFPage() {
|
||||
const { t } = useTranslation()
|
||||
const { data, mutate, error, isLoading } = useSWR<ModelWAFApiMock[]>("/api/v1/waf", swrFetcher)
|
||||
const { profile } = useAuth()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const page = Number(searchParams.get("page")) || 1
|
||||
const pageSize = Number(searchParams.get("pageSize")) || 10
|
||||
|
||||
// 计算 offset
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const { data, mutate, error, isLoading } = useSWR<ModelWAFApiMock>(
|
||||
`/api/v1/waf?offset=${offset}&limit=${pageSize}`,
|
||||
swrFetcher,
|
||||
)
|
||||
|
||||
const isAdmin = profile?.role === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (error)
|
||||
@@ -32,7 +56,7 @@ export default function WAFPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [error])
|
||||
|
||||
const columns: ColumnDef<ModelWAFApiMock>[] = [
|
||||
let columns: ColumnDef<ModelWAF>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
@@ -68,16 +92,23 @@ export default function WAFPage() {
|
||||
{
|
||||
header: t("LastBlockReason"),
|
||||
accessorKey: "lastBlockReason",
|
||||
accessorFn: (row) => row.last_block_reason,
|
||||
cell: ({ row }) => <span>{wafBlockReasons[row.original.last_block_reason] || ""}</span>,
|
||||
accessorFn: (row) => row.block_reason,
|
||||
cell: ({ row }) => <span>{wafBlockReasons[row.original.block_reason] || ""}</span>,
|
||||
},
|
||||
{
|
||||
header: t("LastBlockIdentifier"),
|
||||
accessorKey: "lastBlockIdentifier",
|
||||
accessorFn: (row) => (
|
||||
<span>{wafBlockIdentifiers[row.block_identifier] || row.block_identifier}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("LastBlockTime"),
|
||||
accessorKey: "lastBlockTime",
|
||||
accessorFn: (row) => row.last_block_timestamp,
|
||||
accessorFn: (row) => row.block_timestamp,
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
const date = new Date((s.last_block_timestamp || 0) * 1000)
|
||||
const date = new Date((s.block_timestamp || 0) * 1000)
|
||||
return <span>{date.toISOString()}</span>
|
||||
},
|
||||
},
|
||||
@@ -102,8 +133,13 @@ export default function WAFPage() {
|
||||
},
|
||||
]
|
||||
|
||||
if (!isAdmin) {
|
||||
// 非管理员隐藏操作列
|
||||
columns = columns.filter((c) => c.id !== "actions")
|
||||
}
|
||||
|
||||
const dataCache = useMemo(() => {
|
||||
return data ?? []
|
||||
return data?.value ?? []
|
||||
}, [data])
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -114,20 +150,123 @@ export default function WAFPage() {
|
||||
|
||||
const selectedRows = table.getSelectedRowModel().rows
|
||||
|
||||
const renderPagination = () => {
|
||||
if (!data?.pagination) return null
|
||||
|
||||
const { total } = data.pagination
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage < 1 || newPage > totalPages) return
|
||||
setSearchParams({ page: newPage.toString(), pageSize: pageSize.toString() })
|
||||
}
|
||||
|
||||
// 计算要显示的页码范围
|
||||
const getPageNumbers = () => {
|
||||
const pages: number[] = []
|
||||
const maxVisiblePages = 5
|
||||
|
||||
if (totalPages <= maxVisiblePages) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
// 始终显示第一页
|
||||
pages.push(1)
|
||||
|
||||
let startPage = Math.max(2, page - 1)
|
||||
let endPage = Math.min(totalPages - 1, page + 1)
|
||||
|
||||
if (page <= 3) {
|
||||
endPage = Math.min(maxVisiblePages - 1, totalPages - 1)
|
||||
} else if (page >= totalPages - 2) {
|
||||
startPage = Math.max(2, totalPages - (maxVisiblePages - 2))
|
||||
}
|
||||
|
||||
if (startPage > 2) {
|
||||
pages.push(-1) // 表示省略号
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (endPage < totalPages - 1) {
|
||||
pages.push(-1) // 表示省略号
|
||||
}
|
||||
|
||||
// 始终显示最后一页
|
||||
if (totalPages > 1) {
|
||||
pages.push(totalPages)
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 py-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("Total")}: {total}
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
className={
|
||||
page <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPageNumbers().map((pageNum, idx) =>
|
||||
pageNum === -1 ? (
|
||||
<PaginationItem key={`ellipsis-${idx}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
isActive={pageNum === page}
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
className={
|
||||
page >= totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<SettingsTab className="mt-6 w-full" />
|
||||
<div className="flex mt-4 mb-4">
|
||||
<HeaderButtonGroup
|
||||
className="flex-2 flex gap-2 ml-auto"
|
||||
delete={{
|
||||
fn: deleteWAF,
|
||||
id: selectedRows.map((r) => ip16Str(r.original.ip ?? "")),
|
||||
mutate: mutate,
|
||||
}}
|
||||
>
|
||||
<></>
|
||||
</HeaderButtonGroup>
|
||||
{isAdmin && (
|
||||
<HeaderButtonGroup
|
||||
className="flex-2 flex gap-2 ml-auto"
|
||||
delete={{
|
||||
fn: deleteWAF,
|
||||
id: selectedRows.map((r) => ip16Str(r.original.ip ?? "")),
|
||||
mutate: mutate,
|
||||
}}
|
||||
>
|
||||
<></>
|
||||
</HeaderButtonGroup>
|
||||
)}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -174,6 +313,7 @@ export default function WAFPage() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{renderPagination()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -404,6 +404,8 @@ export interface ModelProfile {
|
||||
password: string
|
||||
updated_at: string
|
||||
username: string
|
||||
role: number
|
||||
agent_secret: string
|
||||
}
|
||||
|
||||
export interface ModelProfileForm {
|
||||
@@ -673,6 +675,7 @@ export interface ModelUser {
|
||||
password: string
|
||||
updated_at: string
|
||||
username: string
|
||||
role: number
|
||||
}
|
||||
|
||||
export interface ModelUserForm {
|
||||
@@ -680,9 +683,32 @@ export interface ModelUserForm {
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface ModelWAFApiMock {
|
||||
export interface Pagination {
|
||||
limit: number
|
||||
offset: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ModelWAF {
|
||||
count: number
|
||||
ip: string
|
||||
last_block_reason: number
|
||||
last_block_timestamp: number
|
||||
block_identifier: number
|
||||
block_reason: number
|
||||
block_timestamp: number
|
||||
}
|
||||
|
||||
export interface ModelWAFApiMock {
|
||||
pagination: Pagination
|
||||
value: ModelWAF[]
|
||||
}
|
||||
|
||||
export interface ModelOnlineUser {
|
||||
connected_at: string
|
||||
ip: string
|
||||
user_id: number
|
||||
}
|
||||
|
||||
export interface ModelOnlineUserApi {
|
||||
pagination: Pagination
|
||||
value: ModelOnlineUser[]
|
||||
}
|
||||
|
||||
@@ -16,3 +16,10 @@ export const wafBlockReasons: Record<number, string> = {
|
||||
2: i18n.t("BruteForceAttackingToken"),
|
||||
3: i18n.t("BruteForceAttackingAgentSecret"),
|
||||
}
|
||||
|
||||
export const wafBlockIdentifiers: Record<number, string> = {
|
||||
"-127": i18n.t("GrpcAuthFailed"),
|
||||
"-126": i18n.t("APITokenInvalid"),
|
||||
"-125": i18n.t("UserInvalid"),
|
||||
"-124": i18n.t("BlockByUser"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user