- 新增 NodeBatchSheet 与 ServerBatchSheet,支持批量选中后一键编辑公共字段 - Server 表单:引入 mergeProtocol / buildSanitizedProtocols,过滤空字符串避免 Zod 枚举校验失败;renderGroupCard 改为具名导出供批量 Sheet 复用;优化嵌套错误提示递归取第一条 - 新增 parseDeviceType 工具函数,从 User-Agent 解析设备平台(iPhone/Android/Mac 等) - 用户列表:登录标识旁展示设备平台 Badge - 注册安全设置表单:引入 Textarea 组件并完善字段布局 - 类型定义补充及 i18n 本地化更新 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import { useNode } from "@/stores/node";
|
||||
import { useServer } from "@/stores/server";
|
||||
import DynamicMultiplier from "./dynamic-multiplier";
|
||||
import OnlineUsersCell from "./online-users-cell";
|
||||
import ServerBatchSheet from "./server-batch-sheet";
|
||||
import ServerConfig from "./server-config";
|
||||
import ServerForm from "./server-form";
|
||||
import ServerInstall from "./server-install";
|
||||
@@ -184,6 +185,14 @@ export default function Servers() {
|
||||
isServerReferencedByNodes(row.id)
|
||||
);
|
||||
return [
|
||||
<ServerBatchSheet
|
||||
key="batch-update"
|
||||
onSuccess={() => {
|
||||
ref.current?.refresh();
|
||||
fetchServers();
|
||||
}}
|
||||
rows={rows}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
"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 { Checkbox } from "@workspace/ui/components/checkbox";
|
||||
import { Form } from "@workspace/ui/components/form";
|
||||
import { Label } from "@workspace/ui/components/label";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
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 { cn } from "@workspace/ui/lib/utils";
|
||||
import { updateServer } from "@workspace/ui/services/admin/server";
|
||||
import { useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
formSchema,
|
||||
getProtocolDefaultConfig,
|
||||
protocols as PROTOCOLS,
|
||||
useProtocolFields,
|
||||
} from "./form-schema";
|
||||
import { renderGroupCard } from "./server-form";
|
||||
|
||||
type SimpleFieldKey = "name" | "country" | "city" | "address";
|
||||
type FieldKey = SimpleFieldKey | "protocols";
|
||||
|
||||
interface SimplePatch {
|
||||
name: string;
|
||||
country: string;
|
||||
city: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export default function ServerBatchSheet({
|
||||
rows,
|
||||
onSuccess,
|
||||
}: {
|
||||
rows: API.Server[];
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [accordionValue, setAccordionValue] = useState<string>();
|
||||
|
||||
const [enabledFields, setEnabledFields] = useState<Set<FieldKey>>(new Set());
|
||||
const [patch, setPatch] = useState<SimplePatch>({
|
||||
name: "",
|
||||
country: "",
|
||||
city: "",
|
||||
address: "",
|
||||
});
|
||||
|
||||
const PROTOCOL_FIELDS = useProtocolFields();
|
||||
|
||||
const protocolForm = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
address: "",
|
||||
country: "",
|
||||
city: "",
|
||||
protocols: PROTOCOLS.map((type) => getProtocolDefaultConfig(type)),
|
||||
},
|
||||
});
|
||||
|
||||
const protocolsValues = useWatch({
|
||||
control: protocolForm.control,
|
||||
name: "protocols",
|
||||
});
|
||||
|
||||
function toggleField(key: FieldKey) {
|
||||
setEnabledFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function isEnabled(key: FieldKey) {
|
||||
return enabledFields.has(key);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (rows.length === 0) return;
|
||||
if (enabledFields.size === 0) {
|
||||
toast.warning(
|
||||
t(
|
||||
"batch_no_fields_selected",
|
||||
"Please select at least one field to update"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate enabled simple fields
|
||||
if (enabledFields.has("name") && !patch.name.trim()) {
|
||||
toast.warning(t("batch_name_required", "Name cannot be empty"));
|
||||
return;
|
||||
}
|
||||
if (enabledFields.has("address") && !patch.address.trim()) {
|
||||
toast.warning(t("batch_address_required", "Address cannot be empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
const activePatch: Record<string, any> = {};
|
||||
for (const key of enabledFields) {
|
||||
if (key === "protocols") {
|
||||
const formValues = protocolForm.getValues();
|
||||
const filteredProtocols = (formValues.protocols || []).filter(
|
||||
(p: any) => {
|
||||
const port = Number(p?.port);
|
||||
return p && Number.isFinite(port) && port > 0 && port <= 65_535;
|
||||
}
|
||||
);
|
||||
activePatch.protocols = filteredProtocols;
|
||||
} else {
|
||||
activePatch[key] = patch[key];
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
rows.map((row) => {
|
||||
const body: API.UpdateServerRequest = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
country: row.country as string | undefined,
|
||||
city: row.city as string | undefined,
|
||||
address: row.address,
|
||||
sort: row.sort,
|
||||
protocols: row.protocols,
|
||||
...activePatch,
|
||||
};
|
||||
return updateServer(body);
|
||||
})
|
||||
);
|
||||
|
||||
const succeeded = results.filter((r) => r.status === "fulfilled").length;
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
|
||||
if (failed === 0) {
|
||||
toast.success(
|
||||
t("batch_updated", "Updated {{count}} servers", { count: succeeded })
|
||||
);
|
||||
setOpen(false);
|
||||
setEnabledFields(new Set());
|
||||
onSuccess();
|
||||
} else if (succeeded > 0) {
|
||||
toast.warning(
|
||||
t("batch_partial", "{{succeeded}} updated, {{failed}} failed", {
|
||||
succeeded,
|
||||
failed,
|
||||
})
|
||||
);
|
||||
onSuccess();
|
||||
} else {
|
||||
toast.error(t("batch_update_failed", "Batch update failed"));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
setEnabledFields(new Set());
|
||||
setPatch({ name: "", country: "", city: "", address: "" });
|
||||
protocolForm.reset({
|
||||
name: "",
|
||||
address: "",
|
||||
country: "",
|
||||
city: "",
|
||||
protocols: PROTOCOLS.map((type) => getProtocolDefaultConfig(type)),
|
||||
});
|
||||
setAccordionValue(undefined);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
const simpleFields: Array<{
|
||||
key: SimpleFieldKey;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
}> = [
|
||||
{ key: "name", label: t("name", "Name") },
|
||||
{ key: "country", label: t("country", "Country") },
|
||||
{ key: "city", label: t("city", "City") },
|
||||
{
|
||||
key: "address",
|
||||
label: t("address", "Address"),
|
||||
placeholder: t("address_placeholder", "Server address"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button onClick={handleOpen} variant="outline">
|
||||
{t("batch_update", "Batch Update")}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent className="w-[700px] max-w-full gap-0 md:max-w-3xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("batch_update_title", "Batch Update ({{count}} servers)", {
|
||||
count: rows.length,
|
||||
})}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6 pt-4">
|
||||
<p className="mb-4 text-muted-foreground text-sm">
|
||||
{t(
|
||||
"batch_update_desc",
|
||||
"Check the fields you want to overwrite. Unchecked fields will keep their original values."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Form {...protocolForm}>
|
||||
<form className="space-y-5">
|
||||
{/* Simple text fields */}
|
||||
{simpleFields.map(({ key, label, placeholder }) => (
|
||||
<div className="flex items-start gap-3" key={key}>
|
||||
<Checkbox
|
||||
checked={isEnabled(key)}
|
||||
id={`batch-server-${key}`}
|
||||
onCheckedChange={() => toggleField(key)}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={isEnabled(key) ? "" : "text-muted-foreground"}
|
||||
htmlFor={`batch-server-${key}`}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<div
|
||||
className={
|
||||
isEnabled(key) ? "" : "pointer-events-none opacity-40"
|
||||
}
|
||||
>
|
||||
<EnhancedInput
|
||||
onValueChange={(v) =>
|
||||
setPatch((p) => ({ ...p, [key]: String(v ?? "") }))
|
||||
}
|
||||
placeholder={placeholder}
|
||||
value={patch[key]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Protocol Configurations */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled("protocols")}
|
||||
id="batch-server-protocols"
|
||||
onCheckedChange={() => toggleField("protocols")}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={
|
||||
isEnabled("protocols") ? "" : "text-muted-foreground"
|
||||
}
|
||||
htmlFor="batch-server-protocols"
|
||||
>
|
||||
{t("protocol_configurations", "Protocol Configurations")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"protocol_configurations_desc",
|
||||
"Enable and configure the required protocol types"
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
className={
|
||||
isEnabled("protocols")
|
||||
? ""
|
||||
: "pointer-events-none opacity-40"
|
||||
}
|
||||
>
|
||||
<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 isProtocolEnabled = 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>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
isProtocolEnabled
|
||||
? "text-green-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isProtocolEnabled
|
||||
? t("enabled", "Enabled")
|
||||
: t("disabled", "Disabled")}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!isProtocolEnabled}
|
||||
className="mr-2"
|
||||
onCheckedChange={(checked) => {
|
||||
protocolForm.setValue(
|
||||
`protocols.${i}.enable` as any,
|
||||
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",
|
||||
protocolForm.control,
|
||||
protocolForm,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
{renderGroupCard(
|
||||
t("obfs", "Obfuscation"),
|
||||
fields,
|
||||
"obfs",
|
||||
protocolForm.control,
|
||||
protocolForm,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
{renderGroupCard(
|
||||
t("transport", "Transport"),
|
||||
fields,
|
||||
"transport",
|
||||
protocolForm.control,
|
||||
protocolForm,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
{renderGroupCard(
|
||||
t("security", "Security"),
|
||||
fields,
|
||||
"security",
|
||||
protocolForm.control,
|
||||
protocolForm,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
{renderGroupCard(
|
||||
t("reality", "Reality"),
|
||||
fields,
|
||||
"reality",
|
||||
protocolForm.control,
|
||||
protocolForm,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
{renderGroupCard(
|
||||
t("encryption", "Encryption"),
|
||||
fields,
|
||||
"encryption",
|
||||
protocolForm.control,
|
||||
protocolForm,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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={handleSubmit}>
|
||||
{t("confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -184,7 +184,9 @@ function DynamicField({
|
||||
{...fieldProps}
|
||||
max={field.max}
|
||||
min={field.min}
|
||||
onValueChange={(v) => fieldProps.onChange(v)}
|
||||
onValueChange={(v) =>
|
||||
fieldProps.onChange(v === "" ? undefined : Number(v))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
step={field.step || 1}
|
||||
suffix={field.suffix}
|
||||
@@ -308,7 +310,7 @@ function renderFieldsByGroup(
|
||||
);
|
||||
}
|
||||
|
||||
function renderGroupCard(
|
||||
export function renderGroupCard(
|
||||
title: string,
|
||||
fields: FieldConfig[],
|
||||
group: string,
|
||||
@@ -362,15 +364,52 @@ export default function ServerForm(props: {
|
||||
const { isProtocolUsedInNodes } = useNode();
|
||||
const PROTOCOL_FIELDS = useProtocolFields();
|
||||
|
||||
/**
|
||||
* mergeProtocol - 将后端返回的协议数据与前端默认配置安全合并。
|
||||
* 过滤掉空字符串 "",避免覆盖默认的枚举值(如 flow="none"),
|
||||
* 从而防止 Zod 枚举校验失败。
|
||||
* @param existingProtocol - 后端返回的协议对象(可能含空字符串)
|
||||
* @param defaultConfig - 前端定义的安全默认配置
|
||||
* @returns 合并后的协议对象
|
||||
*/
|
||||
function mergeProtocol(
|
||||
existingProtocol: Record<string, any> | undefined,
|
||||
defaultConfig: Record<string, any>
|
||||
) {
|
||||
if (!existingProtocol) return defaultConfig;
|
||||
const merged = { ...defaultConfig };
|
||||
for (const key in existingProtocol) {
|
||||
if (Object.hasOwn(existingProtocol, key)) {
|
||||
const val = existingProtocol[key];
|
||||
// 空字符串降级为默认值,避免 Zod 枚举校验错误
|
||||
if (val !== "") {
|
||||
merged[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* buildSanitizedProtocols - 为所有协议类型构建安全的初始表单数据。
|
||||
* @param protocols - 后端返回的已有协议数组
|
||||
* @returns 清洗后的完整协议配置数组
|
||||
*/
|
||||
function buildSanitizedProtocols(protocols?: any[]) {
|
||||
return PROTOCOLS.map((type) => {
|
||||
const existing = protocols?.find((p) => p.type === type);
|
||||
return mergeProtocol(existing as any, getProtocolDefaultConfig(type));
|
||||
});
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
address: "",
|
||||
country: "",
|
||||
city: "",
|
||||
protocols: [] as any[],
|
||||
...initialValues,
|
||||
name: initialValues?.name ?? "",
|
||||
address: initialValues?.address ?? "",
|
||||
country: initialValues?.country ?? "",
|
||||
city: initialValues?.city ?? "",
|
||||
protocols: buildSanitizedProtocols(initialValues?.protocols as any[]),
|
||||
},
|
||||
});
|
||||
const { control } = form;
|
||||
@@ -380,20 +419,11 @@ export default function ServerForm(props: {
|
||||
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;
|
||||
}),
|
||||
name: initialValues.name ?? "",
|
||||
address: initialValues.address ?? "",
|
||||
country: initialValues.country ?? "",
|
||||
city: initialValues.city ?? "",
|
||||
protocols: buildSanitizedProtocols(initialValues.protocols as any[]),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -686,8 +716,22 @@ export default function ServerForm(props: {
|
||||
<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));
|
||||
const getFirstErrorMessage = (
|
||||
errObj: any
|
||||
): string | undefined => {
|
||||
if (!errObj) return;
|
||||
if (errObj.message && typeof errObj.message === "string")
|
||||
return errObj.message;
|
||||
for (const k in errObj) {
|
||||
if (Object.hasOwn(errObj, k)) {
|
||||
const msg = getFirstErrorMessage(errObj[k]);
|
||||
if (msg) return msg;
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
const msg = getFirstErrorMessage(errors);
|
||||
if (msg) toast.error(msg);
|
||||
return false;
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -27,10 +27,12 @@ type Props = {
|
||||
server: API.Server;
|
||||
};
|
||||
|
||||
const DEFAULT_API_HOST = "https://api.hifast.biz";
|
||||
|
||||
export default function ServerInstall({ server }: Props) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [domain, setDomain] = useState("");
|
||||
const [domain, setDomain] = useState(DEFAULT_API_HOST);
|
||||
|
||||
const { data: cfgResp } = useQuery({
|
||||
queryKey: ["getNodeConfig"],
|
||||
@@ -43,8 +45,7 @@ export default function ServerInstall({ server }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const host = localStorage.getItem("API_HOST") ?? window.location.origin;
|
||||
setDomain(host);
|
||||
setDomain(DEFAULT_API_HOST);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -75,7 +76,6 @@ export default function ServerInstall({ server }: Props) {
|
||||
|
||||
const onDomainChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setDomain(e.target.value);
|
||||
localStorage.setItem("API_HOST", e.target.value);
|
||||
}, []);
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
|
||||
Reference in New Issue
Block a user