diff --git a/src/api/settings.ts b/src/api/settings.ts new file mode 100644 index 0000000..61156bb --- /dev/null +++ b/src/api/settings.ts @@ -0,0 +1,10 @@ +import { ModelSettingForm, ModelConfig } from "@/types" +import { fetcher, FetcherMethod } from "./api" + +export const updateSettings = async (data: ModelSettingForm): Promise => { + return fetcher(FetcherMethod.PATCH, `/api/v1/setting`, data); +} + +export const getSettings = async (): Promise => { + return fetcher(FetcherMethod.GET, '/api/v1/setting', null); +} diff --git a/src/api/user.ts b/src/api/user.ts index 6c031cf..d8cafc1 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -1,10 +1,18 @@ -import { ModelUser } from "@/types" +import { ModelProfile, ModelUserForm } from "@/types" import { fetcher, FetcherMethod } from "./api" -export const getProfile = async (): Promise => { - return fetcher(FetcherMethod.GET, '/api/v1/profile', null); +export const getProfile = async (): Promise => { + return fetcher(FetcherMethod.GET, '/api/v1/profile', null); } export const login = async (username: string, password: string): Promise => { return fetcher(FetcherMethod.POST, '/api/v1/login', { username, password }); } + +export const createUser = async (data: ModelUserForm): Promise => { + return fetcher(FetcherMethod.POST, '/api/v1/user', data); +} + +export const deleteUser = async (id: number[]): Promise => { + return fetcher(FetcherMethod.POST, '/api/v1/batch-delete/user', id); +} diff --git a/src/api/waf.ts b/src/api/waf.ts new file mode 100644 index 0000000..9e34f73 --- /dev/null +++ b/src/api/waf.ts @@ -0,0 +1,10 @@ +import { ModelWAF } from "@/types" +import { fetcher, FetcherMethod } from "./api" + +export const deleteWAF = async (ip: string[]): Promise => { + return fetcher(FetcherMethod.POST, '/api/v1/batch-delete/waf', ip); +} + +export const getWAFList = async (): Promise => { + return fetcher(FetcherMethod.GET, '/api/v1/waf', null); +} diff --git a/src/components/action-button-group.tsx b/src/components/action-button-group.tsx index b639879..9c9bb01 100644 --- a/src/components/action-button-group.tsx +++ b/src/components/action-button-group.tsx @@ -13,13 +13,13 @@ import { import { KeyedMutator } from "swr"; import { buttonVariants } from "@/components/ui/button" -interface ButtonGroupProps { +interface ButtonGroupProps { className?: string; children: React.ReactNode; - delete: { fn: (id: number[]) => Promise, id: number, mutate: KeyedMutator }; + delete: { fn: (id: E[]) => Promise, id: E, mutate: KeyedMutator }; } -export function ActionButtonGroup({ className, children, delete: { fn, id, mutate } }: ButtonGroupProps) { +export function ActionButtonGroup({ className, children, delete: { fn, id, mutate } }: ButtonGroupProps) { const handleDelete = async () => { await fn([id]); await mutate(); diff --git a/src/components/header-button-group.tsx b/src/components/header-button-group.tsx index 4ecfc80..3e881ed 100644 --- a/src/components/header-button-group.tsx +++ b/src/components/header-button-group.tsx @@ -14,13 +14,13 @@ import { import { KeyedMutator } from "swr"; import { toast } from "sonner" -interface ButtonGroupProps { +interface ButtonGroupProps { className?: string; children?: React.ReactNode; - delete: { fn: (id: number[]) => Promise, id: number[], mutate: KeyedMutator }; + delete: { fn: (id: E[]) => Promise, id: E[], mutate: KeyedMutator }; } -export function HeaderButtonGroup({ className, children, delete: { fn, id, mutate } }: ButtonGroupProps) { +export function HeaderButtonGroup({ className, children, delete: { fn, id, mutate } }: ButtonGroupProps) { const handleDelete = async () => { await fn(id); await mutate(); diff --git a/src/components/header.tsx b/src/components/header.tsx index 611dbfd..f2cda57 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -10,8 +10,8 @@ import { Card } from "./ui/card"; import { useMainStore } from "@/hooks/useMainStore"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { NzNavigationMenuLink } from "./xui/navigation-menu"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "./ui/dropdown-menu"; -import { User, LogOut } from "lucide-react"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { LogOut, Settings } from "lucide-react"; import { useAuth } from "@/hooks/useAuth"; import { Link, useLocation } from "react-router-dom"; import { useMediaQuery } from "@/hooks/useMediaQuery"; @@ -47,6 +47,7 @@ export default function Header() { const isDesktop = useMediaQuery("(min-width: 890px)") const [open, setOpen] = useState(false) + const [dropdownOpen, setDropdownOpen] = useState(false); return ( isDesktop ? ( @@ -105,7 +106,7 @@ export default function Header() { { profile && <> - + @@ -116,14 +117,16 @@ export default function Header() { {profile.username} - - - Profile - ⇧⌘P + { setDropdownOpen(false) }}> + + + Settings + ⇧⌘P + - + Log out ⇧⌘Q @@ -171,13 +174,13 @@ export default function Header() { } - NEZHA + NEZHA
{ profile && <> - + @@ -188,14 +191,16 @@ export default function Header() { {profile.username} - - - Profile - ⇧⌘P + { setDropdownOpen(false) }}> + + + Settings + ⇧⌘P + - + Log out ⇧⌘Q diff --git a/src/components/service.tsx b/src/components/service.tsx index 5e99d05..f3775ca 100644 --- a/src/components/service.tsx +++ b/src/components/service.tsx @@ -77,7 +77,7 @@ export const ServiceCard: React.FC = ({ data, mutate }) => { resolver: zodResolver(serviceFormSchema), defaultValues: data ? { ...data, - skip_servers_raw: conv.recordToStrArr(data.skip_servers), + skip_servers_raw: conv.recordToStrArr(data.skip_servers ? data.skip_servers : {}), } : { type: 1, cover: 0, diff --git a/src/components/settings-tab.tsx b/src/components/settings-tab.tsx new file mode 100644 index 0000000..47c22d0 --- /dev/null +++ b/src/components/settings-tab.tsx @@ -0,0 +1,26 @@ +import { + Tabs, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs" +import { Link, useLocation } from "react-router-dom" + +export const SettingsTab = ({ className }: { className?: string }) => { + const location = useLocation(); + + return ( + + + + Config + + + User + + + WAF + + + + ) +} diff --git a/src/components/user.tsx b/src/components/user.tsx new file mode 100644 index 0000000..c511d90 --- /dev/null +++ b/src/components/user.tsx @@ -0,0 +1,120 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { ScrollArea } from "@/components/ui/scroll-area" +import { useForm } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { ModelUser } from "@/types" +import { useState } from "react" +import { KeyedMutator } from "swr" +import { IconButton } from "@/components/xui/icon-button" +import { createUser } from "@/api/user" + +interface UserCardProps { + mutate: KeyedMutator; +} + +const userFormSchema = z.object({ + username: z.string().min(1), + password: z.string().min(8).max(72), +}); + +export const UserCard: React.FC = ({ mutate }) => { + const form = useForm>({ + resolver: zodResolver(userFormSchema), + defaultValues: { + username: "", + password: "", + }, + resetOptions: { + keepDefaultValues: false, + } + }) + + const [open, setOpen] = useState(false); + + const onSubmit = async (values: z.infer) => { + await createUser(values); + setOpen(false); + await mutate(); + form.reset(); + } + + return ( + + + + + + +
+ + New User + + +
+ + ( + + Username + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + + + + + + +
+
+
+
+ ) +} diff --git a/src/hooks/useNotfication.tsx b/src/hooks/useNotfication.tsx index 3bfd914..bdb78ee 100644 --- a/src/hooks/useNotfication.tsx +++ b/src/hooks/useNotfication.tsx @@ -3,6 +3,7 @@ import { useNotificationStore } from "./useNotificationStore" import { getNotificationGroups } from "@/api/notification-group" import { getNotification } from "@/api/notification" import { NotificationContextProps } from "@/types" +import { useLocation } from "react-router-dom" const NotificationContext = createContext({}); @@ -19,6 +20,8 @@ export const NotificationProvider: React.FC = ({ chil const notifiers = useNotificationStore(store => store.notifiers); const setNotifier = useNotificationStore(store => store.setNotifier); + const location = useLocation(); + useEffect(() => { if (withNotifierGroup) (async () => { @@ -39,7 +42,7 @@ export const NotificationProvider: React.FC = ({ chil setNotifier(undefined); } })(); - }, []) + }, [location.pathname]) const value: NotificationContextProps = useMemo(() => ({ notifiers: notifiers, diff --git a/src/hooks/useServer.tsx b/src/hooks/useServer.tsx index 84fff9d..a34725c 100644 --- a/src/hooks/useServer.tsx +++ b/src/hooks/useServer.tsx @@ -3,6 +3,7 @@ import { useServerStore } from "./useServerStore" import { getServerGroups } from "@/api/server-group" import { getServers } from "@/api/server" import { ServerContextProps } from "@/types" +import { useLocation } from "react-router-dom" const ServerContext = createContext({}); @@ -19,6 +20,8 @@ export const ServerProvider: React.FC = ({ children, withSe const server = useServerStore(store => store.server); const setServer = useServerStore(store => store.setServer); + const location = useLocation(); + useEffect(() => { if (withServerGroup) (async () => { @@ -39,7 +42,7 @@ export const ServerProvider: React.FC = ({ children, withSe setServer(undefined); } })(); - }, []) + }, [location.pathname]) const value: ServerContextProps = useMemo(() => ({ servers: server, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4d08200..b92c1da 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -139,3 +139,29 @@ export function joinIP(p?: ModelIP) { } return ''; } + +export function ip16Str(p: number[]) { + const buf = new Uint8Array(p); + const ip4 = buf.slice(-6); + if (ip4[0] === 255 && ip4[1] === 255) { + return ip4.slice(2).join('.'); + } + return ipv6BinaryToString(buf); +} + +function ipv6BinaryToString(binary: Uint8Array) { + let parts: string[] = []; + for (let i = 0; i < binary.length; i += 2) { + let hex = (binary[i] << 8 | binary[i + 1]).toString(16); + parts.push(hex); + } + + let ipv6 = parts.join(':'); + + ipv6 = ipv6.replace(/(:0)+$/, ''); + if (ipv6.indexOf('::') === -1 && parts.filter(p => p === '0').length > 1) { + ipv6 = ipv6.replace(/(:0)+/, '::'); + } + + return ipv6; +} diff --git a/src/main.tsx b/src/main.tsx index b404b29..1578333 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -24,6 +24,9 @@ import { NotificationProvider } from './hooks/useNotfication'; import CronPage from './routes/cron'; import NotificationPage from './routes/notification'; import AlertRulePage from './routes/alert-rule'; +import SettingsPage from './routes/settings'; +import UserPage from './routes/user'; +import WAFPage from './routes/waf'; const router = createBrowserRouter([ { @@ -87,6 +90,18 @@ const router = createBrowserRouter([ path: "/dashboard/terminal/:id", element: , }, + { + path: "/dashboard/settings", + element: , + }, + { + path: "/dashboard/settings/user", + element: , + }, + { + path: "/dashboard/settings/waf", + element: , + }, ] }, ]); diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx new file mode 100644 index 0000000..06bc805 --- /dev/null +++ b/src/routes/settings.tsx @@ -0,0 +1,320 @@ +import { useEffect, useState } from "react" +import { toast } from "sonner" +import { ModelConfig, settingCoverageTypes, nezhaLang } from "@/types" +import { SettingsTab } from "@/components/settings-tab" +import { z } from "zod" +import { asOptionalField } from "@/lib/utils" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { getSettings, updateSettings } from "@/api/settings" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, +} from "@/components/ui/card" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +const settingFormSchema = z.object({ + custom_nameservers: asOptionalField(z.string()), + ignored_ip_notification: asOptionalField(z.string()), + ip_change_notification_group_id: z.coerce.number().int().min(0), + cover: z.coerce.number().int().min(1), + site_name: z.string().min(1), + language: z.string().min(2), + install_host: asOptionalField(z.string()), + custom_code: asOptionalField(z.string()), + custom_code_dashboard: asOptionalField(z.string()), + real_ip_header: asOptionalField(z.string()), + + enable_ip_change_notification: asOptionalField(z.boolean()), + enable_plain_ip_in_notification: asOptionalField(z.boolean()), +}); + +export default function SettingsPage() { + const [config, setConfig] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + if (error) + toast("Error", { + description: `Error fetching resource: ${error.message}.`, + }) + }, [error]) + + useEffect(() => { + (async () => { + try { + const c = await getSettings(); + setConfig(c); + } catch (e) { + if (e instanceof Error) setError(e); + } + })() + }, []) + + const form = useForm>({ + resolver: zodResolver(settingFormSchema), + defaultValues: config ? config : { + ip_change_notification_group_id: 0, + cover: 1, + site_name: "", + language: "", + }, + resetOptions: { + keepDefaultValues: false, + } + }) + + useEffect(() => { + if (config) { + form.reset(config); + } + }, [config, form]); + + const onSubmit = async (values: z.infer) => { + try { + await updateSettings(values); + const newConfig = await getSettings(); + setConfig(newConfig); + form.reset(); + } catch (e) { + if (e instanceof Error) setError(e); + return; + } finally { + toast("Success", { + description: "Config updated successfully.", + }) + } + } + + return ( +
+ +
+
+ + ( + + Site Name + + + + + + )} + /> + ( + + Language + + + + + + )} + /> + ( + + Custom Codes (Style and Script) + +