704 lines
23 KiB
TypeScript
704 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
} from "@workspace/ui/components/accordion";
|
|
import { Badge } from "@workspace/ui/components/badge";
|
|
import { Button } from "@workspace/ui/components/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@workspace/ui/components/dropdown-menu";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@workspace/ui/components/form";
|
|
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@workspace/ui/components/select";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetTrigger,
|
|
} from "@workspace/ui/components/sheet";
|
|
import { Switch } from "@workspace/ui/components/switch";
|
|
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
|
import { Icon } from "@workspace/ui/composed/icon";
|
|
import { cn } from "@workspace/ui/lib/utils";
|
|
import { useEffect, useState } from "react";
|
|
import { useForm, useWatch } from "react-hook-form";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import { useNode } from "@/stores/node";
|
|
import {
|
|
type FieldConfig,
|
|
formSchema,
|
|
getLabel,
|
|
getProtocolDefaultConfig,
|
|
protocols as PROTOCOLS,
|
|
useProtocolFields,
|
|
} from "./form-schema";
|
|
|
|
function DynamicField({
|
|
field,
|
|
control,
|
|
form,
|
|
protocolIndex,
|
|
protocolData,
|
|
}: {
|
|
field: FieldConfig;
|
|
control: any;
|
|
form: any;
|
|
protocolIndex: number;
|
|
protocolData: any;
|
|
}) {
|
|
const fieldName = `protocols.${protocolIndex}.${field.name}` as const;
|
|
|
|
if (field.condition && !field.condition(protocolData, {})) {
|
|
return null;
|
|
}
|
|
|
|
const commonProps = {
|
|
control,
|
|
name: fieldName,
|
|
};
|
|
|
|
switch (field.type) {
|
|
case "input":
|
|
return (
|
|
<FormField
|
|
{...commonProps}
|
|
render={({ field: fieldProps }) => (
|
|
<FormItem>
|
|
<FormLabel>{field.label}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
{...fieldProps}
|
|
onValueChange={(v) => fieldProps.onChange(v)}
|
|
placeholder={field.placeholder}
|
|
suffix={
|
|
field.generate ? (
|
|
field.generate.functions &&
|
|
field.generate.functions.length > 0 ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button size="sm" type="button" variant="ghost">
|
|
<Icon className="h-4 w-4" icon="mdi:key" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{field.generate.functions.map((genFunc, idx) => (
|
|
<DropdownMenuItem
|
|
key={idx}
|
|
onClick={async () => {
|
|
const result = await genFunc.function();
|
|
if (typeof result === "string") {
|
|
fieldProps.onChange(result);
|
|
} else if (field.generate!.updateFields) {
|
|
Object.entries(
|
|
field.generate!.updateFields
|
|
).forEach(([fieldName, resultKey]) => {
|
|
const fullFieldName = `protocols.${protocolIndex}.${fieldName}`;
|
|
form.setValue(
|
|
fullFieldName,
|
|
(result as any)[resultKey]
|
|
);
|
|
});
|
|
} else if (result.privateKey) {
|
|
fieldProps.onChange(result.privateKey);
|
|
}
|
|
}}
|
|
>
|
|
{genFunc.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : field.generate.function ? (
|
|
<Button
|
|
onClick={async () => {
|
|
const result = await field.generate!.function!();
|
|
if (typeof result === "string") {
|
|
fieldProps.onChange(result);
|
|
} else if (field.generate!.updateFields) {
|
|
Object.entries(
|
|
field.generate!.updateFields
|
|
).forEach(([fieldName, resultKey]) => {
|
|
const fullFieldName = `protocols.${protocolIndex}.${fieldName}`;
|
|
form.setValue(
|
|
fullFieldName,
|
|
(result as any)[resultKey]
|
|
);
|
|
});
|
|
} else if (result.privateKey) {
|
|
fieldProps.onChange(result.privateKey);
|
|
}
|
|
}}
|
|
size="sm"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
<Icon className="h-4 w-4" icon="mdi:key" />
|
|
</Button>
|
|
) : null
|
|
) : (
|
|
field.suffix
|
|
)
|
|
}
|
|
type="text"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
|
|
case "number":
|
|
return (
|
|
<FormField
|
|
{...commonProps}
|
|
render={({ field: fieldProps }) => (
|
|
<FormItem>
|
|
<FormLabel>{field.label}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
{...fieldProps}
|
|
max={field.max}
|
|
min={field.min}
|
|
onValueChange={(v) => fieldProps.onChange(v)}
|
|
placeholder={field.placeholder}
|
|
step={field.step || 1}
|
|
suffix={field.suffix}
|
|
type="number"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
|
|
case "select":
|
|
if (!field.options || field.options.length <= 1) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<FormField
|
|
{...commonProps}
|
|
render={({ field: fieldProps }) => (
|
|
<FormItem>
|
|
<FormLabel>{field.label}</FormLabel>
|
|
<FormControl>
|
|
<Select
|
|
onValueChange={(v) => fieldProps.onChange(v)}
|
|
value={fieldProps.value ?? field.defaultValue}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{field.options?.map((option) => (
|
|
<SelectItem key={option} value={option}>
|
|
{getLabel(option)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
|
|
case "switch":
|
|
return (
|
|
<FormField
|
|
{...commonProps}
|
|
render={({ field: fieldProps }) => (
|
|
<FormItem>
|
|
<FormLabel>{field.label}</FormLabel>
|
|
<FormControl>
|
|
<div className="pt-2">
|
|
<Switch
|
|
checked={!!fieldProps.value}
|
|
onCheckedChange={(checked) => fieldProps.onChange(checked)}
|
|
/>
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
|
|
case "textarea":
|
|
return (
|
|
<FormField
|
|
{...commonProps}
|
|
render={({ field: fieldProps }) => (
|
|
<FormItem className="col-span-2">
|
|
<FormLabel>{field.label}</FormLabel>
|
|
<FormControl>
|
|
<textarea
|
|
{...fieldProps}
|
|
className="flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
onChange={(e) => fieldProps.onChange(e.target.value)}
|
|
placeholder={field.placeholder}
|
|
value={fieldProps.value ?? ""}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function renderFieldsByGroup(
|
|
fields: FieldConfig[],
|
|
group: string,
|
|
control: any,
|
|
form: any,
|
|
protocolIndex: number,
|
|
protocolData: any
|
|
) {
|
|
const groupFields = fields.filter((field) => field.group === group);
|
|
if (groupFields.length === 0) return null;
|
|
|
|
return (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{groupFields.map((field) => (
|
|
<DynamicField
|
|
control={control}
|
|
field={field}
|
|
form={form}
|
|
key={field.name}
|
|
protocolData={protocolData}
|
|
protocolIndex={protocolIndex}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderGroupCard(
|
|
title: string,
|
|
fields: FieldConfig[],
|
|
group: string,
|
|
control: any,
|
|
form: any,
|
|
protocolIndex: number,
|
|
protocolData: any
|
|
) {
|
|
const groupFields = fields.filter((field) => field.group === group);
|
|
if (groupFields.length === 0) return null;
|
|
|
|
const visibleFields = groupFields.filter(
|
|
(field) => !field.condition || field.condition(protocolData, {})
|
|
);
|
|
|
|
if (visibleFields.length === 0) return null;
|
|
|
|
return (
|
|
<div className="relative">
|
|
<fieldset className="rounded-lg border border-border">
|
|
<legend className="ml-3 bg-background px-1 py-1 font-medium text-foreground text-sm">
|
|
{title}
|
|
</legend>
|
|
<div className="p-4 pt-2">
|
|
{renderFieldsByGroup(
|
|
fields,
|
|
group,
|
|
control,
|
|
form,
|
|
protocolIndex,
|
|
protocolData
|
|
)}
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ServerForm(props: {
|
|
trigger: string;
|
|
title: string;
|
|
loading?: boolean;
|
|
initialValues?: Partial<API.Server>;
|
|
onSubmit: (values: Partial<API.Server>) => Promise<boolean> | boolean;
|
|
}) {
|
|
const { trigger, title, loading, initialValues, onSubmit } = props;
|
|
const { t } = useTranslation("servers");
|
|
const [open, setOpen] = useState(false);
|
|
const [accordionValue, setAccordionValue] = useState<string>();
|
|
|
|
const { isProtocolUsedInNodes } = useNode();
|
|
const PROTOCOL_FIELDS = useProtocolFields();
|
|
|
|
const form = useForm({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
name: "",
|
|
address: "",
|
|
country: "",
|
|
city: "",
|
|
protocols: [] as any[],
|
|
...initialValues,
|
|
},
|
|
});
|
|
const { control } = form;
|
|
|
|
const protocolsValues = useWatch({ control, name: "protocols" });
|
|
|
|
useEffect(() => {
|
|
if (initialValues) {
|
|
form.reset({
|
|
name: "",
|
|
address: "",
|
|
country: "",
|
|
city: "",
|
|
...initialValues,
|
|
protocols: PROTOCOLS.map((type) => {
|
|
const existingProtocol = initialValues.protocols?.find(
|
|
(p) => p.type === type
|
|
);
|
|
const defaultConfig = getProtocolDefaultConfig(type);
|
|
return existingProtocol
|
|
? { ...defaultConfig, ...existingProtocol }
|
|
: defaultConfig;
|
|
}),
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [initialValues]);
|
|
|
|
async function handleSubmit(values: Record<string, any>) {
|
|
const filteredProtocols = (values?.protocols || []).filter(
|
|
(protocol: any) => {
|
|
const port = Number(protocol?.port);
|
|
return protocol && Number.isFinite(port) && port > 0 && port <= 65_535;
|
|
}
|
|
);
|
|
|
|
const result = {
|
|
name: values.name,
|
|
country: values.country,
|
|
city: values.city,
|
|
address: values.address,
|
|
protocols: filteredProtocols,
|
|
};
|
|
|
|
const ok = await onSubmit(result);
|
|
if (ok) {
|
|
form.reset();
|
|
setOpen(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Sheet onOpenChange={setOpen} open={open}>
|
|
<SheetTrigger asChild>
|
|
<Button
|
|
onClick={() => {
|
|
if (!initialValues) {
|
|
const full = PROTOCOLS.map((t) => getProtocolDefaultConfig(t));
|
|
form.reset({
|
|
name: "",
|
|
address: "",
|
|
country: "",
|
|
city: "",
|
|
protocols: full,
|
|
});
|
|
}
|
|
setOpen(true);
|
|
}}
|
|
>
|
|
{trigger}
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent className="w-[700px] max-w-full gap-0 md:max-w-3xl">
|
|
<SheetHeader>
|
|
<SheetTitle>{title}</SheetTitle>
|
|
</SheetHeader>
|
|
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]">
|
|
<Form {...form}>
|
|
<form className="grid grid-cols-1 gap-2 px-6 pt-4">
|
|
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
|
<FormField
|
|
control={control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("name", "Name")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
{...field}
|
|
onValueChange={(v) => field.onChange(v)}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={control}
|
|
name="address"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("address", "Address")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
{...field}
|
|
onValueChange={(v) => field.onChange(v)}
|
|
placeholder={t(
|
|
"address_placeholder",
|
|
"Server address"
|
|
)}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={control}
|
|
name="country"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("country", "Country")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
{...field}
|
|
onValueChange={(v) => field.onChange(v)}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={control}
|
|
name="city"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("city", "City")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
{...field}
|
|
onValueChange={(v) => field.onChange(v)}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
<div className="my-3">
|
|
<h3 className="font-semibold text-foreground text-sm">
|
|
{t("protocol_configurations", "Protocol Configurations")}
|
|
</h3>
|
|
<p className="mt-1 text-muted-foreground text-xs">
|
|
{t(
|
|
"protocol_configurations_desc",
|
|
"Enable and configure the required protocol types"
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<Accordion
|
|
className="w-full space-y-3"
|
|
collapsible
|
|
onValueChange={setAccordionValue}
|
|
type="single"
|
|
value={accordionValue}
|
|
>
|
|
{PROTOCOLS.map((type) => {
|
|
const i = Math.max(0, PROTOCOLS.indexOf(type));
|
|
const current = (protocolsValues[i] || {}) as Record<
|
|
string,
|
|
any
|
|
>;
|
|
const isEnabled = current?.enable;
|
|
const fields = PROTOCOL_FIELDS[type] || [];
|
|
return (
|
|
<AccordionItem
|
|
className="mb-2 rounded-lg border"
|
|
key={type}
|
|
value={type}
|
|
>
|
|
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
|
<div className="flex w-full items-center justify-between">
|
|
<div className="flex flex-col items-start gap-1">
|
|
<div className="flex items-center gap-1">
|
|
<span className="font-medium capitalize">
|
|
{type}
|
|
</span>
|
|
{current.transport && (
|
|
<Badge className="text-xs" variant="secondary">
|
|
{current.transport.toUpperCase()}
|
|
</Badge>
|
|
)}
|
|
{current.security &&
|
|
current.security !== "none" && (
|
|
<Badge className="text-xs" variant="outline">
|
|
{current.security.toUpperCase()}
|
|
</Badge>
|
|
)}
|
|
{current.port && (
|
|
<Badge className="text-xs">
|
|
{current.port}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span
|
|
className={cn(
|
|
"text-xs",
|
|
isEnabled
|
|
? "text-green-500"
|
|
: "text-muted-foreground"
|
|
)}
|
|
>
|
|
{isEnabled
|
|
? t("enabled", "Enabled")
|
|
: t("disabled", "Disabled")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
checked={!!isEnabled}
|
|
className="mr-2"
|
|
disabled={Boolean(
|
|
initialValues?.id &&
|
|
isProtocolUsedInNodes(
|
|
initialValues?.id || 0,
|
|
type
|
|
) &&
|
|
isEnabled
|
|
)}
|
|
onCheckedChange={(checked) => {
|
|
form.setValue(`protocols.${i}.enable`, checked);
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-4 pt-0 pb-4">
|
|
<div className="-mx-4 space-y-4 rounded-b-lg border-t px-4 pt-4">
|
|
{renderGroupCard(
|
|
t("basic", "Basic Configuration"),
|
|
fields,
|
|
"basic",
|
|
control,
|
|
form,
|
|
i,
|
|
current
|
|
)}
|
|
{renderGroupCard(
|
|
t("obfs", "Obfuscation"),
|
|
fields,
|
|
"obfs",
|
|
control,
|
|
form,
|
|
i,
|
|
current
|
|
)}
|
|
{renderGroupCard(
|
|
t("transport", "Transport"),
|
|
fields,
|
|
"transport",
|
|
control,
|
|
form,
|
|
i,
|
|
current
|
|
)}
|
|
{renderGroupCard(
|
|
t("security", "Security"),
|
|
fields,
|
|
"security",
|
|
control,
|
|
form,
|
|
i,
|
|
current
|
|
)}
|
|
{renderGroupCard(
|
|
t("reality", "Reality"),
|
|
fields,
|
|
"reality",
|
|
control,
|
|
form,
|
|
i,
|
|
current
|
|
)}
|
|
{renderGroupCard(
|
|
t("encryption", "Encryption"),
|
|
fields,
|
|
"encryption",
|
|
control,
|
|
form,
|
|
i,
|
|
current
|
|
)}
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
);
|
|
})}
|
|
</Accordion>
|
|
</form>
|
|
</Form>
|
|
</ScrollArea>
|
|
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
|
<Button
|
|
disabled={loading}
|
|
onClick={() => setOpen(false)}
|
|
variant="outline"
|
|
>
|
|
{t("cancel", "Cancel")}
|
|
</Button>
|
|
<Button
|
|
disabled={loading}
|
|
onClick={form.handleSubmit(handleSubmit, (errors) => {
|
|
const key = Object.keys(errors)[0] as keyof typeof errors;
|
|
if (key) toast.error(String(errors[key]?.message));
|
|
return false;
|
|
})}
|
|
>
|
|
{loading && (
|
|
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
|
)}
|
|
{t("confirm", "Confirm")}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|