diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml
index 7a11d25de..c67482794 100644
--- a/.github/workflows/docs-ci.yml
+++ b/.github/workflows/docs-ci.yml
@@ -11,6 +11,9 @@ on:
- 'docs/**'
- '.github/workflows/docs-ci.yml'
+permissions:
+ contents: read
+
defaults:
run:
working-directory: docs
diff --git a/frontend/.gitignore b/frontend/.gitignore
index 7874edd7b..52c12c13c 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -3,3 +3,4 @@ node_modules/
*.log
*.tsbuildinfo
coverage/
+storybook-static/
diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit
new file mode 100644
index 000000000..f450749af
--- /dev/null
+++ b/frontend/.husky/pre-commit
@@ -0,0 +1 @@
+cd frontend && npx lint-staged
diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
new file mode 100644
index 000000000..efa98482e
--- /dev/null
+++ b/frontend/.storybook/main.ts
@@ -0,0 +1,25 @@
+import type { StorybookConfig } from '@storybook/react-vite';
+
+const config: StorybookConfig = {
+ framework: {
+ name: '@storybook/react-vite',
+ options: {},
+ },
+ stories: ['../src/**/*.stories.@(ts|tsx)'],
+ addons: ['@storybook/addon-docs', '@storybook/addon-a11y'],
+ viteFinal: (viteConfig) => {
+ if (viteConfig.build) {
+ viteConfig.build.outDir = undefined;
+ viteConfig.build.emptyOutDir = false;
+ if (viteConfig.build.rollupOptions) {
+ viteConfig.build.rollupOptions.input = undefined;
+ }
+ }
+ if (viteConfig.experimental) {
+ viteConfig.experimental.renderBuiltUrl = undefined;
+ }
+ return viteConfig;
+ },
+};
+
+export default config;
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
new file mode 100644
index 000000000..adb801ada
--- /dev/null
+++ b/frontend/.storybook/preview.tsx
@@ -0,0 +1,60 @@
+import { useEffect } from 'react';
+import type { Decorator, Preview } from '@storybook/react-vite';
+import { ConfigProvider, theme as antdTheme } from 'antd';
+import i18next from 'i18next';
+import { initReactI18next } from 'react-i18next';
+
+import enUS from '../../internal/web/translation/en-US.json';
+
+if (!i18next.isInitialized) {
+ void i18next.use(initReactI18next).init({
+ lng: 'en-US',
+ fallbackLng: 'en-US',
+ resources: { 'en-US': { translation: enUS } },
+ interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
+ returnNull: false,
+ });
+}
+
+const withTheme: Decorator = (Story, context) => {
+ const dark = context.globals.theme === 'dark';
+ useEffect(() => {
+ document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
+ }, [dark]);
+ return (
+
+
+
+
+
+ );
+};
+
+const preview: Preview = {
+ decorators: [withTheme],
+ globalTypes: {
+ theme: {
+ description: 'Ant Design theme',
+ defaultValue: 'light',
+ toolbar: {
+ title: 'Theme',
+ icon: 'circlehollow',
+ items: [
+ { value: 'light', title: 'Light' },
+ { value: 'dark', title: 'Dark' },
+ ],
+ dynamicTitle: true,
+ },
+ },
+ },
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ },
+};
+
+export default preview;
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index e5c43450f..0c3ad445d 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -27,12 +27,19 @@ The `@` import alias maps to `src/`.
`src/lib/xray/`. HTTP goes through `HttpUtil` in `src/utils/index.ts`.
## Rules
-- Ant Design 6 only; no Tailwind/shadcn (a migration was rolled back).
+- Ant Design 6 for components; no Tailwind/shadcn (a migration was rolled back).
+ Form *state* runs on React Hook Form (`src/components/form/rhf/`), not Ant
+ Design's `Form` store.
- Function components + hooks only; no class components.
- No `//` line comments in committed TS/TSX. HTML comments are fine.
-- TS strict; `no-explicit-any` is an error. Validate form fields with
- `antdRule(Schema.shape.field, t)` from `@/utils/zodForm`, not inline
- `z.string()`.
+- TS strict; `no-explicit-any` is an error. Build forms with `useZodForm` +
+ `FormField` from `@/components/form/rhf` (wrap the tree in `FormProvider`);
+ validate through the `zodResolver` or per-field
+ `rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod
+ issue keys resolved via `t()`, never inline `z.string()`. AntD `
+
+
+ v ?? '' }}
+ >
+ ({ value: g }))}
+ allowClear
+ autoFocus
+ />
+
+
+
>
);
diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx
index 753936c14..6f5083cc3 100644
--- a/frontend/src/pages/clients/ClientBulkAddModal.tsx
+++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx
@@ -4,11 +4,13 @@ import { AutoComplete, Button, Form, Input, InputNumber, Modal, Select, Space, S
import { ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { RandomUtil, SizeFormatter } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
import { useClients, type InboundOption } from '@/hooks/useClients';
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
@@ -19,6 +21,24 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
]);
+const EMPTY: ClientBulkAddFormValues = {
+ emailMethod: 0,
+ firstNum: 1,
+ lastNum: 1,
+ emailPrefix: '',
+ emailPostfix: '',
+ quantity: 1,
+ subId: '',
+ group: '',
+ comment: '',
+ flow: '',
+ limitIp: 0,
+ totalGB: 0,
+ expiryTime: 0,
+ reset: 0,
+ inboundIds: [],
+};
+
interface ClientBulkAddModalProps {
open: boolean;
inbounds: InboundOption[];
@@ -27,28 +47,6 @@ interface ClientBulkAddModalProps {
onSaved?: () => void;
}
-type FormState = ClientBulkAddFormValues;
-
-function emptyForm(): FormState {
- return {
- emailMethod: 0,
- firstNum: 1,
- lastNum: 1,
- emailPrefix: '',
- emailPostfix: '',
- quantity: 1,
- subId: '',
- group: '',
- comment: '',
- flow: '',
- limitIp: 0,
- totalGB: 0,
- expiryTime: 0,
- reset: 0,
- inboundIds: [],
- };
-}
-
export default function ClientBulkAddModal({
open,
inbounds,
@@ -60,7 +58,14 @@ export default function ClientBulkAddModal({
const [messageApi, messageContextHolder] = message.useMessage();
const { bulkCreate } = useClients();
- const [form, setForm] = useState(emptyForm);
+ const methods = useForm({ defaultValues: EMPTY });
+ const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
+ const emailMethod = useWatch({ control: methods.control, name: 'emailMethod' });
+ const firstNum = useWatch({ control: methods.control, name: 'firstNum' });
+ const flow = useWatch({ control: methods.control, name: 'flow' });
+ const expiryTime = useWatch({ control: methods.control, name: 'expiryTime' });
+ const subId = useWatch({ control: methods.control, name: 'subId' });
+ const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
const [delayedStart, setDelayedStart] = useState(false);
const [saving, setSaving] = useState(false);
const fail2ban = useFail2banStatusQuery();
@@ -70,14 +75,10 @@ export default function ClientBulkAddModal({
useEffect(() => {
if (!open) return;
- setForm(emptyForm());
+ methods.reset(EMPTY);
setDelayedStart(false);
- }, [open]);
-
- function update(key: K, value: FormState[K]) {
- setForm((prev) => ({ ...prev, [key]: value }));
- }
+ }, [open, methods]);
const flowCapableIds = useMemo(() => {
const ids = new Set();
@@ -88,25 +89,25 @@ export default function ClientBulkAddModal({
}, [inbounds]);
const showFlow = useMemo(
- () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
- [form.inboundIds, flowCapableIds],
+ () => (inboundIds || []).some((id) => flowCapableIds.has(id)),
+ [inboundIds, flowCapableIds],
);
const ss2022Method = useMemo(() => {
- for (const id of form.inboundIds || []) {
+ for (const id of inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
const method = ib?.ssMethod;
if (method && method.substring(0, 4) === '2022') return method;
}
return '';
- }, [form.inboundIds, inbounds]);
+ }, [inboundIds, inbounds]);
useEffect(() => {
- if (!showFlow && form.flow) {
+ if (!showFlow && flow) {
- update('flow', '');
+ methods.setValue('flow', '');
}
- }, [showFlow, form.flow]);
+ }, [showFlow, flow, methods]);
const inboundOptions = useMemo(
() => (inbounds || [])
@@ -119,27 +120,27 @@ export default function ClientBulkAddModal({
);
const expiryDate = useMemo(
- () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
- [form.expiryTime],
+ () => (expiryTime > 0 ? dayjs(expiryTime) : null),
+ [expiryTime],
);
- const delayedExpireDays = form.expiryTime < 0 ? form.expiryTime / -86400000 : 0;
+ const delayedExpireDays = expiryTime < 0 ? expiryTime / -86400000 : 0;
- function buildEmails(): string[] {
- const method = form.emailMethod;
+ function buildEmails(values: ClientBulkAddFormValues): string[] {
+ const method = values.emailMethod;
const out: string[] = [];
let start: number;
let end: number;
if (method > 1) {
- start = form.firstNum;
- end = form.lastNum + 1;
+ start = values.firstNum;
+ end = values.lastNum + 1;
} else {
start = 0;
- end = form.quantity;
+ end = values.quantity;
}
- const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
+ const prefix = method > 0 && values.emailPrefix.length > 0 ? values.emailPrefix : '';
const useNum = method > 1;
- const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
+ const postfix = method > 2 && values.emailPostfix.length > 0 ? values.emailPostfix : '';
for (let i = start; i < end; i++) {
let email = '';
if (method !== 4) email = RandomUtil.randomLowerAndNum(10);
@@ -150,12 +151,13 @@ export default function ClientBulkAddModal({
}
async function submit() {
- const validated = ClientBulkAddFormSchema.safeParse(form);
+ const current = methods.getValues();
+ const validated = ClientBulkAddFormSchema.safeParse(current);
if (!validated.success) {
messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
return;
}
- const emails = buildEmails();
+ const emails = buildEmails(current);
if (emails.length === 0) return;
setSaving(true);
@@ -163,22 +165,22 @@ export default function ClientBulkAddModal({
const payloads = emails.map((email) => ({
client: {
email,
- subId: form.subId || RandomUtil.randomLowerAndNum(16),
+ subId: current.subId || RandomUtil.randomLowerAndNum(16),
id: RandomUtil.randomUUID(),
password: ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16),
- flow: showFlow ? (form.flow || '') : '',
- totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
- expiryTime: form.expiryTime,
- reset: Number(form.reset) || 0,
- limitIp: Number(form.limitIp) || 0,
- group: form.group,
- comment: form.comment,
+ flow: showFlow ? (current.flow || '') : '',
+ totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
+ expiryTime: current.expiryTime,
+ reset: Number(current.reset) || 0,
+ limitIp: Number(current.limitIp) || 0,
+ group: current.group,
+ comment: current.comment,
enable: true,
},
- inboundIds: form.inboundIds,
+ inboundIds: current.inboundIds,
}));
const msg = await bulkCreate(payloads);
const ok = msg?.obj?.created ?? 0;
@@ -213,157 +215,156 @@ export default function ClientBulkAddModal({
onOk={submit}
onCancel={() => onOpenChange(false)}
>
-
-
- update('inboundIds', v)}
- />
- update('inboundIds', v)}
- options={inboundOptions}
- placeholder={t('pages.clients.selectInbound')}
- showSearch={{
- filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
- }}
- />
-
-
-
- update('emailMethod', v)}
- options={[
- { value: 0, label: 'Random' },
- { value: 1, label: 'Random + Prefix' },
- { value: 2, label: 'Random + Prefix + Num' },
- { value: 3, label: 'Random + Prefix + Num + Postfix' },
- { value: 4, label: 'Prefix + Num + Postfix' },
- ]}
- />
-
-
- {form.emailMethod > 1 && (
- <>
-
- update('firstNum', Number(v) || 1)} />
-
-
- update('lastNum', Number(v) || 1)} />
-
- >
- )}
- {form.emailMethod > 0 && (
-
- update('emailPrefix', e.target.value)} />
-
- )}
- {form.emailMethod > 2 && (
-
- update('emailPostfix', e.target.value)} />
-
- )}
- {form.emailMethod < 2 && (
-
- update('quantity', Number(v) || 1)} />
-
- )}
-
-
-
- update('subId', e.target.value)}
- style={{ flex: 1 }}
+
+
+
+ methods.setValue('inboundIds', v)}
/>
- }
- onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
- />
-
-
-
-
- ({ value: g }))}
- onChange={(v) => update('group', v ?? '')}
- allowClear
- />
-
-
-
- update('comment', e.target.value)} />
-
-
- {showFlow && (
-
update('flow', v)}
- style={{ width: 220 }}
+ mode="multiple"
+ value={inboundIds}
+ onChange={(v) => methods.setValue('inboundIds', v)}
+ options={inboundOptions}
+ placeholder={t('pages.clients.selectInbound')}
+ showSearch={{
+ filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
+ }}
+ />
+
+
+
+ ({ value: k, label: k })),
+ { value: 0, label: 'Random' },
+ { value: 1, label: 'Random + Prefix' },
+ { value: 2, label: 'Random + Prefix + Num' },
+ { value: 3, label: 'Random + Prefix + Num + Postfix' },
+ { value: 4, label: 'Prefix + Num + Postfix' },
]}
/>
+
+
+ {emailMethod > 1 && (
+ <>
+ Number(v) || 1 }}>
+
+
+ Number(v) || 1 }}>
+
+
+ >
+ )}
+ {emailMethod > 0 && (
+
+
+
+ )}
+ {emailMethod > 2 && (
+
+
+
+ )}
+ {emailMethod < 2 && (
+ Number(v) || 1 }}>
+
+
+ )}
+
+
+
+ methods.setValue('subId', e.target.value)}
+ style={{ flex: 1 }}
+ />
+ }
+ onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))}
+ />
+
- )}
-
-
-
- update('limitIp', Number(v) || 0)} />
-
-
-
+ v ?? '' }}
+ >
+ ({ value: g }))}
+ allowClear
+ />
+
-
- update('totalGB', Number(v) || 0)} />
-
+
+
+
-
- { setDelayedStart(!delayedStart); update('expiryTime', 0); }}
- />
-
+ {showFlow && (
+
+ ({ value: k, label: k })),
+ ]}
+ />
+
+ )}
- {delayedStart ? (
-
- update('expiryTime', -86400000 * (Number(v) || 0))}
+
+
+
+ methods.setValue('limitIp', Number(v) || 0)} />
+
+
+
+
+ Number(v) || 0 }}>
+
+
+
+
+ { setDelayedStart(!delayedStart); methods.setValue('expiryTime', 0); }}
/>
- ) : (
-
- update('expiryTime', next ? next.valueOf() : 0)}
- />
-
- )}
-
- update('reset', Number(v) || 0)}
- />
-
-
+ {delayedStart ? (
+
+ methods.setValue('expiryTime', -86400000 * (Number(v) || 0))}
+ />
+
+ ) : (
+
+ methods.setValue('expiryTime', next ? next.valueOf() : 0)}
+ />
+
+ )}
+
+ Number(v) || 0 }}
+ >
+
+
+
+
>
);
diff --git a/frontend/src/pages/clients/ClientBulkAdjustModal.tsx b/frontend/src/pages/clients/ClientBulkAdjustModal.tsx
index 897a1a940..dbad6dcfa 100644
--- a/frontend/src/pages/clients/ClientBulkAdjustModal.tsx
+++ b/frontend/src/pages/clients/ClientBulkAdjustModal.tsx
@@ -1,14 +1,18 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Modal, Select, message } from 'antd';
+import { FormProvider, useForm } from 'react-hook-form';
-import { ClientBulkAdjustFormSchema } from '@/schemas/client';
+import { ClientBulkAdjustFormSchema, type ClientBulkAdjustFormValues } from '@/schemas/client';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives/flow';
+import { FormField } from '@/components/form/rhf';
const GB = 1024 * 1024 * 1024;
const FLOW_CLEAR = 'none';
+const EMPTY: ClientBulkAdjustFormValues = { addDays: 0, addGB: 0, flow: '' };
+
interface ClientBulkAdjustModalProps {
open: boolean;
count: number;
@@ -19,24 +23,19 @@ interface ClientBulkAdjustModalProps {
export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
- const [addDays, setAddDays] = useState(0);
- const [addGB, setAddGB] = useState(0);
- const [flow, setFlow] = useState('');
const [submitting, setSubmitting] = useState(false);
+ const methods = useForm({ defaultValues: EMPTY });
useEffect(() => {
- if (open) {
- setAddDays(0);
- setAddGB(0);
- setFlow('');
- }
- }, [open]);
+ if (open) methods.reset(EMPTY);
+ }, [open, methods]);
async function handleOk() {
+ const values = methods.getValues();
const validated = ClientBulkAdjustFormSchema.safeParse({
- addDays: Math.trunc(Number(addDays) || 0),
- addGB: Number(addGB) || 0,
- flow,
+ addDays: Math.trunc(Number(values.addDays) || 0),
+ addGB: Number(values.addGB) || 0,
+ flow: values.flow,
});
if (!validated.success) {
messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
@@ -83,37 +82,26 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
style={{ marginBottom: 16 }}
title={t('pages.clients.bulkAdjustHint')}
/>
-
-
- setAddDays(Number(v) || 0)}
- style={{ width: '100%' }}
- step={1}
- precision={0}
- />
-
-
- setAddGB(Number(v) || 0)}
- style={{ width: '100%' }}
- step={1}
- />
-
-
- ({ value: k, label: k })),
- ]}
- />
-
-
+
+
+
+
+
+
+
+
+
+ ({ value: k, label: k })),
+ ]}
+ />
+
+
+
>
);
diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx
index bb0c5a297..daf5a5ccd 100644
--- a/frontend/src/pages/clients/ClientFormModal.tsx
+++ b/frontend/src/pages/clients/ClientFormModal.tsx
@@ -22,15 +22,18 @@ import {
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
+import { FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
+
import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
-import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
+import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
@@ -42,9 +45,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
-// One editable row in the Links tab. `key` is a stable client-side id for React.
interface ExternalLinkRow {
- key: number;
kind: 'link' | 'subscription';
value: string;
}
@@ -93,26 +94,8 @@ interface ClientFormModalProps {
onOpenChange: (open: boolean) => void;
}
-interface FormState {
- email: string;
- subId: string;
- uuid: string;
- password: string;
- auth: string;
- flow: string;
- security: string;
- reverseTag: string;
- totalGB: number;
- expiryDate: Dayjs | null;
- delayedStart: boolean;
- delayedDays: number;
- reset: number;
- limitIp: number;
- tgId: number;
- group: string;
- comment: string;
- enable: boolean;
- inboundIds: number[];
+type Values = ClientFormValues & {
+ expiryDate: number;
externalLinks: ExternalLinkRow[];
wgPrivateKey: string;
wgPublicKey: string;
@@ -120,43 +103,39 @@ interface FormState {
wgAllowedIPs: string;
secret: string;
adTag: string;
-}
+};
-function emptyForm(): FormState {
- return {
- email: '',
- subId: '',
- uuid: '',
- password: '',
- auth: '',
- flow: '',
- security: 'auto',
- reverseTag: '',
- totalGB: 0,
- expiryDate: null,
- delayedStart: false,
- delayedDays: 0,
- reset: 0,
- limitIp: 0,
- tgId: 0,
- group: '',
- comment: '',
- enable: true,
- inboundIds: [],
- externalLinks: [],
- wgPrivateKey: '',
- wgPublicKey: '',
- wgPreSharedKey: '',
- wgAllowedIPs: '',
- secret: '',
- adTag: '',
- };
-}
+const EMPTY: Values = {
+ email: '',
+ subId: '',
+ uuid: '',
+ password: '',
+ auth: '',
+ flow: '',
+ security: 'auto',
+ reverseTag: '',
+ totalGB: 0,
+ expiryDate: 0,
+ delayedStart: false,
+ delayedDays: 0,
+ reset: 0,
+ limitIp: 0,
+ tgId: 0,
+ group: '',
+ comment: '',
+ enable: true,
+ inboundIds: [],
+ externalLinks: [],
+ wgPrivateKey: '',
+ wgPublicKey: '',
+ wgPreSharedKey: '',
+ wgAllowedIPs: '',
+ secret: '',
+ adTag: '',
+};
-let externalLinkRowSeq = 0;
function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
return (links || []).map((l) => ({
- key: (externalLinkRowSeq += 1),
kind: l.kind === 'subscription' ? 'subscription' : 'link',
value: l.value || '',
}));
@@ -189,7 +168,27 @@ export default function ClientFormModal({
const [messageApi, messageContextHolder] = message.useMessage();
const isEdit = mode === 'edit';
- const [form, setForm] = useState(emptyForm);
+ const methods = useForm({ defaultValues: EMPTY });
+ const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
+ const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' });
+ const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' });
+ const enable = useWatch({ control: methods.control, name: 'enable' });
+ const flow = useWatch({ control: methods.control, name: 'flow' });
+ const reverseTag = useWatch({ control: methods.control, name: 'reverseTag' });
+ const secret = useWatch({ control: methods.control, name: 'secret' });
+ const email = useWatch({ control: methods.control, name: 'email' });
+ const uuid = useWatch({ control: methods.control, name: 'uuid' });
+ const password = useWatch({ control: methods.control, name: 'password' });
+ const subId = useWatch({ control: methods.control, name: 'subId' });
+ const auth = useWatch({ control: methods.control, name: 'auth' });
+ const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' });
+ const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
+ const {
+ fields: externalLinkFields,
+ append: appendExternalLink,
+ remove: removeExternalLink,
+ } = useFieldArray({ control: methods.control, name: 'externalLinks' });
+
const [submitting, setSubmitting] = useState(false);
const [resetting, setResetting] = useState(false);
const [clientIps, setClientIps] = useState([]);
@@ -200,29 +199,8 @@ export default function ClientFormModal({
const limitIpDisabled = !fail2ban.usable;
const limitIpNotice = getLimitIpNotice(fail2ban, t);
- function update(key: K, value: FormState[K]) {
- setForm((prev) => ({ ...prev, [key]: value }));
- }
-
function addExternalLinkRow(kind: 'link' | 'subscription') {
- setForm((prev) => ({
- ...prev,
- externalLinks: [...prev.externalLinks, { key: (externalLinkRowSeq += 1), kind, value: '' }],
- }));
- }
-
- function updateExternalLinkRow(key: number, value: string) {
- setForm((prev) => ({
- ...prev,
- externalLinks: prev.externalLinks.map((r) => (r.key === key ? { ...r, value } : r)),
- }));
- }
-
- function removeExternalLinkRow(key: number) {
- setForm((prev) => ({
- ...prev,
- externalLinks: prev.externalLinks.filter((r) => r.key !== key),
- }));
+ appendExternalLink({ kind, value: '' });
}
useEffect(() => {
@@ -231,8 +209,8 @@ export default function ClientFormModal({
if (isEdit && client) {
const et = Number(client.expiryTime) || 0;
- const next: FormState = {
- ...emptyForm(),
+ const seed: Values = {
+ ...EMPTY,
email: client.email || '',
subId: client.subId || '',
uuid: client.uuid || '',
@@ -258,20 +236,20 @@ export default function ClientFormModal({
adTag: client.adTag || '',
};
if (et < 0) {
- next.delayedStart = true;
- next.delayedDays = Math.round(et / -86400000);
- next.expiryDate = null;
+ seed.delayedStart = true;
+ seed.delayedDays = Math.round(et / -86400000);
+ seed.expiryDate = 0;
} else {
- next.delayedStart = false;
- next.delayedDays = 0;
- next.expiryDate = et > 0 ? dayjs(et) : null;
+ seed.delayedStart = false;
+ seed.delayedDays = 0;
+ seed.expiryDate = et > 0 ? et : 0;
}
- setForm(next);
+ methods.reset(seed);
void loadIps();
} else {
const wgKeypair = Wireguard.generateKeypair();
- setForm({
- ...emptyForm(),
+ methods.reset({
+ ...EMPTY,
email: RandomUtil.randomLowerAndNum(10),
uuid: RandomUtil.randomUUID(),
subId: RandomUtil.randomLowerAndNum(16),
@@ -326,105 +304,112 @@ export default function ClientFormModal({
}, [inbounds]);
const mtprotoDomain = useMemo(() => {
- for (const id of form.inboundIds || []) {
+ for (const id of inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
if (ib?.protocol === 'mtproto' && ib.mtprotoDomain) return ib.mtprotoDomain;
}
return 'www.cloudflare.com';
- }, [form.inboundIds, inbounds]);
+ }, [inboundIds, inbounds]);
const ss2022Method = useMemo(() => {
- for (const id of form.inboundIds || []) {
+ for (const id of inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
const method = ib?.ssMethod;
if (method && method.substring(0, 4) === '2022') return method;
}
return '';
- }, [form.inboundIds, inbounds]);
+ }, [inboundIds, inbounds]);
function regeneratePassword() {
- update('password', ss2022Method
+ methods.setValue('password', ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16));
}
const showFlow = useMemo(
- () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
- [form.inboundIds, flowCapableIds],
+ () => (inboundIds || []).some((id) => flowCapableIds.has(id)),
+ [inboundIds, flowCapableIds],
);
const showReverseTag = useMemo(
- () => (form.inboundIds || []).some((id) => vlessLikeIds.has(id)),
- [form.inboundIds, vlessLikeIds],
+ () => (inboundIds || []).some((id) => vlessLikeIds.has(id)),
+ [inboundIds, vlessLikeIds],
);
const showSecurity = useMemo(
- () => (form.inboundIds || []).some((id) => vmessIds.has(id)),
- [form.inboundIds, vmessIds],
+ () => (inboundIds || []).some((id) => vmessIds.has(id)),
+ [inboundIds, vmessIds],
);
const showWireguard = useMemo(
- () => (form.inboundIds || []).some((id) => wireguardIds.has(id)),
- [form.inboundIds, wireguardIds],
+ () => (inboundIds || []).some((id) => wireguardIds.has(id)),
+ [inboundIds, wireguardIds],
);
const showMtproto = useMemo(
- () => (form.inboundIds || []).some((id) => mtprotoIds.has(id)),
- [form.inboundIds, mtprotoIds],
+ () => (inboundIds || []).some((id) => mtprotoIds.has(id)),
+ [inboundIds, mtprotoIds],
);
function regenerateWireguardKeys() {
const kp = Wireguard.generateKeypair();
- setForm((prev) => ({ ...prev, wgPrivateKey: kp.privateKey, wgPublicKey: kp.publicKey }));
+ methods.setValue('wgPrivateKey', kp.privateKey);
+ methods.setValue('wgPublicKey', kp.publicKey);
}
function regenerateMtprotoSecret() {
- update('secret', generateMtprotoSecret(mtprotoDomain));
+ methods.setValue('secret', generateMtprotoSecret(mtprotoDomain));
}
useEffect(() => {
- if (!showFlow && form.flow) {
-
- update('flow', '');
+ if (!showFlow && flow) {
+ methods.setValue('flow', '');
}
- }, [showFlow, form.flow]);
+ }, [showFlow, flow, methods]);
useEffect(() => {
- if (!showReverseTag && form.reverseTag) {
-
- update('reverseTag', '');
+ if (!showReverseTag && reverseTag) {
+ methods.setValue('reverseTag', '');
}
- }, [showReverseTag, form.reverseTag]);
+ }, [showReverseTag, reverseTag, methods]);
useEffect(() => {
if (!ss2022Method) return;
- setForm((prev) => (
- RandomUtil.isShadowsocks2022Password(prev.password, ss2022Method)
- ? prev
- : { ...prev, password: RandomUtil.randomShadowsocksPassword(ss2022Method) }
- ));
- }, [ss2022Method]);
+ const current = methods.getValues('password');
+ if (!RandomUtil.isShadowsocks2022Password(current, ss2022Method)) {
+ methods.setValue('password', RandomUtil.randomShadowsocksPassword(ss2022Method));
+ }
+ }, [ss2022Method, methods]);
useEffect(() => {
- if (showMtproto && !form.secret) {
- update('secret', generateMtprotoSecret(mtprotoDomain));
+ if (showMtproto && !secret) {
+ methods.setValue('secret', generateMtprotoSecret(mtprotoDomain));
}
- }, [showMtproto, form.secret, mtprotoDomain]);
+ }, [showMtproto, secret, mtprotoDomain, methods]);
const inboundOptions = useMemo(
() => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
- .filter((ib) => ib.enable || (form.inboundIds || []).includes(ib.id))
+ .filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
.map((ib) => ({
label: formatInboundLabel(ib.tag, ib.remark),
value: ib.id,
title: formatInboundLabel(ib.tag, ib.remark),
})),
- [inbounds, form.inboundIds],
+ [inbounds, inboundIds],
);
- const linkRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'link'), [form.externalLinks]);
- const subscriptionRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'subscription'), [form.externalLinks]);
+ const expiryDayjs = useMemo(
+ () => (expiryDate > 0 ? dayjs(expiryDate) : null),
+ [expiryDate],
+ );
+
+ const linkRows = externalLinkFields
+ .map((field, index) => ({ field, index }))
+ .filter((row) => row.field.kind === 'link');
+ const subscriptionRows = externalLinkFields
+ .map((field, index) => ({ field, index }))
+ .filter((row) => row.field.kind === 'subscription');
async function loadIps() {
if (!isEdit || !client?.email) return;
@@ -474,64 +459,65 @@ export default function ClientFormModal({
}
async function onSubmit() {
+ const values = methods.getValues();
const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
const validated = schema.safeParse({
- email: form.email,
- subId: form.subId,
- uuid: form.uuid,
- password: form.password,
- auth: form.auth,
- flow: form.flow,
- security: form.security,
- reverseTag: form.reverseTag,
- totalGB: form.totalGB,
- delayedStart: form.delayedStart,
- delayedDays: form.delayedDays,
- reset: form.reset,
- limitIp: form.limitIp,
- tgId: form.tgId,
- group: form.group,
- comment: form.comment,
- enable: form.enable,
- inboundIds: form.inboundIds,
+ email: values.email,
+ subId: values.subId,
+ uuid: values.uuid,
+ password: values.password,
+ auth: values.auth,
+ flow: values.flow,
+ security: values.security,
+ reverseTag: values.reverseTag,
+ totalGB: values.totalGB,
+ delayedStart: values.delayedStart,
+ delayedDays: values.delayedDays,
+ reset: values.reset,
+ limitIp: values.limitIp,
+ tgId: values.tgId,
+ group: values.group,
+ comment: values.comment,
+ enable: values.enable,
+ inboundIds: values.inboundIds,
});
if (!validated.success) {
const issue = validated.error.issues[0];
messageApi.error(t(issue?.message ?? 'somethingWentWrong'));
return;
}
- const expiryTime = form.delayedStart
- ? -86400000 * (Number(form.delayedDays) || 0)
- : (form.expiryDate ? form.expiryDate.valueOf() : 0);
+ const expiryTime = values.delayedStart
+ ? -86400000 * (Number(values.delayedDays) || 0)
+ : (values.expiryDate || 0);
const clientPayload: Record = {
- email: form.email.trim(),
- subId: form.subId,
- id: form.uuid,
- password: form.password,
- auth: form.auth,
- flow: showFlow ? (form.flow || '') : '',
- security: showSecurity ? (form.security || 'auto') : 'auto',
- totalGB: gbToBytes(form.totalGB),
+ email: values.email.trim(),
+ subId: values.subId,
+ id: values.uuid,
+ password: values.password,
+ auth: values.auth,
+ flow: showFlow ? (values.flow || '') : '',
+ security: showSecurity ? (values.security || 'auto') : 'auto',
+ totalGB: gbToBytes(values.totalGB),
expiryTime,
- reset: Number(form.reset) || 0,
- limitIp: Number(form.limitIp) || 0,
- tgId: Number(form.tgId) || 0,
- group: form.group,
- comment: form.comment,
- enable: !!form.enable,
+ reset: Number(values.reset) || 0,
+ limitIp: Number(values.limitIp) || 0,
+ tgId: Number(values.tgId) || 0,
+ group: values.group,
+ comment: values.comment,
+ enable: !!values.enable,
};
- const reverseTag = showReverseTag ? (form.reverseTag || '').trim() : '';
- if (reverseTag) {
- clientPayload.reverse = { tag: reverseTag };
+ const reverseTagValue = showReverseTag ? (values.reverseTag || '').trim() : '';
+ if (reverseTagValue) {
+ clientPayload.reverse = { tag: reverseTagValue };
}
if (showWireguard) {
- clientPayload.privateKey = form.wgPrivateKey;
- clientPayload.publicKey = form.wgPublicKey;
- if (form.wgPreSharedKey) {
- clientPayload.preSharedKey = form.wgPreSharedKey;
+ clientPayload.privateKey = values.wgPrivateKey;
+ clientPayload.publicKey = values.wgPublicKey;
+ if (values.wgPreSharedKey) {
+ clientPayload.preSharedKey = values.wgPreSharedKey;
}
- const allowedIPs = form.wgAllowedIPs
+ const allowedIPs = values.wgAllowedIPs
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
@@ -541,16 +527,16 @@ export default function ClientFormModal({
}
if (showMtproto) {
- const adTag = form.adTag.trim();
+ const adTag = values.adTag.trim();
if (adTag !== '' && !/^[0-9a-fA-F]{32}$/.test(adTag)) {
messageApi.error(t('pages.inbounds.form.mtgAdTagInvalid'));
return;
}
- clientPayload.secret = form.secret;
+ clientPayload.secret = values.secret;
clientPayload.adTag = adTag;
}
- const externalLinks: ExternalLinkInput[] = form.externalLinks
+ const externalLinks: ExternalLinkInput[] = values.externalLinks
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
.filter((r) => r.value !== '');
@@ -559,7 +545,7 @@ export default function ClientFormModal({
let msg;
if (isEdit && client) {
const original = new Set(attachedIds || []);
- const next = new Set(form.inboundIds || []);
+ const next = new Set(values.inboundIds || []);
const toAttach = [...next].filter((id) => !original.has(id));
const toDetach = [...original].filter((id) => !next.has(id));
msg = await save(clientPayload, {
@@ -571,7 +557,7 @@ export default function ClientFormModal({
});
} else {
msg = await save(
- { client: clientPayload, inboundIds: form.inboundIds },
+ { client: clientPayload, inboundIds: values.inboundIds },
{ isEdit: false, email: clientPayload.email as string, externalLinks },
);
}
@@ -618,335 +604,344 @@ export default function ClientFormModal({
}
>
-
-
-
-
-
-
- update('email', e.target.value)}
- />
- {!isEdit && (
- } onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
- )}
-
-
-
-
-
- update('totalGB', Number(v) || 0)} />
-
-
-
-
-
-
-
- update('limitIp', Number(v) || 0)} />
- {isEdit && (
-
- } loading={ipsLoading} onClick={openIpsModal}>
- {clientIps.length > 0 ? clientIps.length : ''}
-
-
- )}
-
-
-
-
-
-
-
-
-
- {form.delayedStart ? (
-
- update('delayedDays', Number(v) || 0)} />
-
- ) : (
-
- update('expiryDate', d || null)}
- />
-
- )}
-
-
-
- {
- update('delayedStart', v);
- if (v) update('expiryDate', null);
- else update('delayedDays', 0);
- }}
- />
-
-
-
-
- update('reset', Number(v) || 0)} />
-
-
-
-
-
-
-
- update('comment', e.target.value)} />
-
-
-
-
- ({ value: g }))}
- onChange={(v) => update('group', v ?? '')}
- allowClear
- />
-
-
-
-
- {(tgBotEnable || showReverseTag) && (
+
+
+
- {tgBotEnable && (
-
-
- update('tgId', Number(v) || 0)} />
-
-
- )}
- {showReverseTag && (
-
-
- update('reverseTag', e.target.value)} />
-
-
- )}
+
+
+
+ methods.setValue('email', e.target.value)}
+ />
+ {!isEdit && (
+ } onClick={() => methods.setValue('email', RandomUtil.randomLowerAndNum(12))} />
+ )}
+
+
+
+
+ Number(v) || 0 }}
+ >
+
+
+
+
+
+
+
+
+ methods.setValue('limitIp', Number(v) || 0)} />
+ {isEdit && (
+
+ } loading={ipsLoading} onClick={openIpsModal}>
+ {clientIps.length > 0 ? clientIps.length : ''}
+
+
+ )}
+
+
+
+
+
- )}
-
- update('inboundIds', v)}
- />
- update('inboundIds', v)}
- options={inboundOptions}
- placeholder={t('pages.clients.selectInbound')}
- maxTagCount="responsive"
- placement="topLeft"
- listHeight={220}
- showSearch={{
- filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
- }}
- />
-
-
-
- update('enable', v)} />
- {t('enable')}
-
- >
- ),
- },
- {
- key: 'config',
- label: t('pages.clients.tabCredentials'),
- children: (
- <>
-
-
- update('uuid', e.target.value)} />
- } onClick={() => update('uuid', RandomUtil.randomUUID())} />
-
-
-
-
-
- update('password', e.target.value)} />
- } onClick={regeneratePassword} />
-
-
-
-
-
- update('subId', e.target.value)} />
- } onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
-
-
-
-
-
- update('auth', e.target.value)} />
- } onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
-
-
-
- {showFlow && (
-
- update('flow', v)}
- options={[
- { value: '', label: t('none') },
- ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
- ]}
- />
-
- )}
- {showSecurity && (
-
- update('security', v)}
- options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
- />
-
- )}
- {showWireguard && (
- <>
-
-
- {
- const priv = e.target.value;
- update('wgPrivateKey', priv);
- update('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
+
+
+ {delayedStart ? (
+ Number(v) || 0 }}
+ >
+
+
+ ) : (
+
+ methods.setValue('expiryDate', d ? d.valueOf() : 0)}
+ />
+
+ )}
+
+
+
+ {
+ methods.setValue('delayedStart', v);
+ if (v) methods.setValue('expiryDate', 0);
+ else methods.setValue('delayedDays', 0);
}}
/>
- } onClick={regenerateWireguardKeys} />
-
-
-
-
-
-
- update('wgPreSharedKey', e.target.value)}
- />
-
-
- update('wgAllowedIPs', e.target.value)}
- />
-
- >
- )}
- {showMtproto && (
- <>
-
-
- update('secret', e.target.value)} />
- } onClick={regenerateMtprotoSecret} />
-
-
-
- update('adTag', e.target.value)}
- />
-
- >
- )}
- >
- ),
- },
- {
- key: 'links',
- label: t('pages.clients.tabLinks'),
- children: (
- <>
-
- {t('pages.clients.linksHint')}
-
+
+
+
+ Number(v) || 0 }}
+ >
+
+
+
+
- } onClick={() => addExternalLinkRow('link')}>
- {t('pages.clients.addExternalLink')}
-
-
- {linkRows.length === 0 ? (
-
{t('pages.clients.noExternalLinks')}
- ) : linkRows.map((row) => (
-
- updateExternalLinkRow(row.key, e.target.value)}
- placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
- />
-
- } onClick={() => removeExternalLinkRow(row.key)} />
-
-
- ))}
-
+
+
+
+
+
+
+
+ v ?? '' }}
+ >
+ ({ value: g }))}
+ allowClear
+ />
+
+
+
- } onClick={() => addExternalLinkRow('subscription')}>
- {t('pages.clients.addExternalSubscription')}
-
-
- {subscriptionRows.length === 0 ? (
-
{t('pages.clients.noExternalSubscriptions')}
- ) : subscriptionRows.map((row) => (
-
- updateExternalLinkRow(row.key, e.target.value)}
- placeholder="https://provider.example/sub/…"
+ {(tgBotEnable || showReverseTag) && (
+
+ {tgBotEnable && (
+
+ Number(v) || 0 }}
+ >
+
+
+
+ )}
+ {showReverseTag && (
+
+
+
+
+
+ )}
+
+ )}
+
+
+ methods.setValue('inboundIds', v)}
+ />
+ methods.setValue('inboundIds', v)}
+ options={inboundOptions}
+ placeholder={t('pages.clients.selectInbound')}
+ maxTagCount="responsive"
+ placement="topLeft"
+ listHeight={220}
+ showSearch={{
+ filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
+ }}
+ />
+
+
+
+ methods.setValue('enable', v)} />
+ {t('enable')}
+
+ >
+ ),
+ },
+ {
+ key: 'config',
+ label: t('pages.clients.tabCredentials'),
+ children: (
+ <>
+
+
+ methods.setValue('uuid', e.target.value)} />
+ } onClick={() => methods.setValue('uuid', RandomUtil.randomUUID())} />
+
+
+
+
+
+ methods.setValue('password', e.target.value)} />
+ } onClick={regeneratePassword} />
+
+
+
+
+
+ methods.setValue('subId', e.target.value)} />
+ } onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))} />
+
+
+
+
+
+ methods.setValue('auth', e.target.value)} />
+ } onClick={() => methods.setValue('auth', RandomUtil.randomLowerAndNum(16))} />
+
+
+
+ {showFlow && (
+
+ ({ value: k, label: k })),
+ ]}
/>
-
- } onClick={() => removeExternalLinkRow(row.key)} />
-
-
- ))}
-
- >
- ),
- },
- ]}
- />
-
+
+ )}
+ {showSecurity && (
+
+ ({ value: k, label: k }))}
+ />
+
+ )}
+ {showWireguard && (
+ <>
+
+
+ {
+ const priv = e.target.value;
+ methods.setValue('wgPrivateKey', priv);
+ methods.setValue('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
+ }}
+ />
+ } onClick={regenerateWireguardKeys} />
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {showMtproto && (
+ <>
+
+
+ methods.setValue('secret', e.target.value)} />
+ } onClick={regenerateMtprotoSecret} />
+
+
+
+
+
+ >
+ )}
+ >
+ ),
+ },
+ {
+ key: 'links',
+ label: t('pages.clients.tabLinks'),
+ children: (
+ <>
+
+ {t('pages.clients.linksHint')}
+
+
+ } onClick={() => addExternalLinkRow('link')}>
+ {t('pages.clients.addExternalLink')}
+
+
+ {linkRows.length === 0 ? (
+
{t('pages.clients.noExternalLinks')}
+ ) : linkRows.map(({ field, index }) => (
+
+
+
+
+
+ } onClick={() => removeExternalLink(index)} />
+
+
+ ))}
+
+
+ } onClick={() => addExternalLinkRow('subscription')}>
+ {t('pages.clients.addExternalSubscription')}
+
+
+ {subscriptionRows.length === 0 ? (
+
{t('pages.clients.noExternalSubscriptions')}
+ ) : subscriptionRows.map(({ field, index }) => (
+
+
+
+
+
+ } onClick={() => removeExternalLink(index)} />
+
+
+ ))}
+
+ >
+ ),
+ },
+ ]}
+ />
+
+
& { enable: boolean; inboundId?: number };
interface HostFormModalProps {
@@ -74,19 +78,21 @@ function defaultsFor(host: HostRecord | null): FormShape {
export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
- const [form] = Form.useForm();
+ const methods = useForm({ defaultValues: defaultsFor(host) });
- // Drive conditional field visibility off the selected security, like the
- // legacy externalProxy form: same/none inherit fully and hide every TLS/cert
- // field; reality shows only the reality-relevant subset (its keys are
- // inherited from the inbound); tls shows the full TLS override set.
- const security = (Form.useWatch('security', form) ?? 'same') as string;
+ /*
+ * Drive conditional field visibility off the selected security, like the
+ * legacy externalProxy form: same/none inherit fully and hide every TLS/cert
+ * field; reality shows only the reality-relevant subset (its keys are
+ * inherited from the inbound); tls shows the full TLS override set.
+ */
+ const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
const showTls = security === 'tls' || security === 'reality';
const showTlsExtras = security === 'tls';
useEffect(() => {
- if (open) form.setFieldsValue(defaultsFor(host));
- }, [open, host, form]);
+ if (open) methods.reset(defaultsFor(host));
+ }, [open, host, methods]);
const { nodes } = useNodesQuery();
@@ -108,13 +114,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
- const onOk = async () => {
- let values: FormShape;
- try {
- values = await form.validateFields();
- } catch {
- return;
- }
+ const onFinish = async (values: FormShape) => {
const { enable, ...rest } = values;
const payload: Partial = { ...rest, isDisabled: !enable };
const res = await save(payload);
@@ -130,7 +130,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
onOpenChange(false)}
okText={t('save')}
cancelText={t('cancel')}
@@ -138,198 +138,215 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
width={isMobile ? '95vw' : 760}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
>
-
- , t('pages.hosts.sections.basic'), isMobile),
- children: (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- ),
- },
- {
- key: 'security',
- forceRender: true,
- label: catTabLabel( , t('pages.hosts.sections.security'), isMobile),
- children: (
- <>
-
- ({ value: v, label: v }))}
- />
-
- {showTls && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
- {showTlsExtras && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
- >
- ),
- },
- {
- key: 'advanced',
- forceRender: true,
- label: catTabLabel( , t('pages.hosts.sections.advanced'), isMobile),
- children: (
- , t('pages.hosts.sections.general'), isMobile),
- children: (
- <>
-
-
-
-
-
-
-
-
-
-
- ({ value: v, label: v }))}
+
+
+ , t('pages.hosts.sections.basic'), isMobile),
+ children: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ },
+ {
+ key: 'security',
+ forceRender: true,
+ label: catTabLabel( , t('pages.hosts.sections.security'), isMobile),
+ children: (
+ <>
+
+ ({ value: v, label: v }))}
+ />
+
+ {showTls && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {showTlsExtras && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ ),
+ },
+ {
+ key: 'advanced',
+ forceRender: true,
+ label: catTabLabel( , t('pages.hosts.sections.advanced'), isMobile),
+ children: (
+ , t('pages.hosts.sections.general'), isMobile),
+ children: (
+ <>
+
+
+
+
+
+
+
+
+
+
+ ({ value: v, label: v }))}
+ />
+
+ >
+ ),
+ },
+ {
+ key: 'adv-mux',
+ forceRender: true,
+ label: catTabLabel( , t('pages.hosts.fields.muxParams'), isMobile),
+ children: (
+
+ (
+
+ )}
/>
- >
- ),
- },
- {
- key: 'adv-mux',
- forceRender: true,
- label: catTabLabel( , t('pages.hosts.fields.muxParams'), isMobile),
- children: (
-
-
-
- ),
- },
- {
- key: 'adv-sockopt',
- forceRender: true,
- label: catTabLabel( , t('pages.hosts.fields.sockoptParams'), isMobile),
- children: (
-
-
-
- ),
- },
- {
- key: 'adv-finalmask',
- forceRender: true,
- label: catTabLabel( , t('pages.hosts.fields.finalMask'), isMobile),
- children: (
-
-
-
- ),
- },
- ]}
- />
- ),
- },
- {
- key: 'clash',
- forceRender: true,
- label: catTabLabel( , t('pages.hosts.sections.clash'), isMobile),
- children: (
- <>
-
- ({ value: v, label: v }))}
- />
-
-
-
-
-
-
-
- >
- ),
- },
- ]}
- />
-
+ ),
+ },
+ {
+ key: 'adv-sockopt',
+ forceRender: true,
+ label: catTabLabel( , t('pages.hosts.fields.sockoptParams'), isMobile),
+ children: (
+
+ (
+
+ )}
+ />
+
+ ),
+ },
+ {
+ key: 'adv-finalmask',
+ forceRender: true,
+ label: catTabLabel( , t('pages.hosts.fields.finalMask'), isMobile),
+ children: (
+
+ (
+
+ )}
+ />
+
+ ),
+ },
+ ]}
+ />
+ ),
+ },
+ {
+ key: 'clash',
+ forceRender: true,
+ label: catTabLabel( , t('pages.hosts.sections.clash'), isMobile),
+ children: (
+ <>
+
+ ({ value: v, label: v }))}
+ />
+
+
+
+
+
+
+
+ >
+ ),
+ },
+ ]}
+ />
+
+
);
}
diff --git a/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx b/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx
index 53624eca4..809e5b3f6 100644
--- a/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx
+++ b/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx
@@ -3,10 +3,12 @@ import { MuxForm } from '@/pages/xray/outbounds/transport';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers';
-// Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
-// settings editor). Stored in the host's muxParams JSON string. Defaults match
-// the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
-// when the toggle is off, an explicit mux object when on.
+/*
+ * Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
+ * settings editor). Stored in the host's muxParams JSON string. Defaults match
+ * the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
+ * when the toggle is off, an explicit mux object when on.
+ */
const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' };
export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
@@ -17,9 +19,9 @@ export default function HostMuxForm({ value, onChange }: { value?: string; onCha
path={['mux']}
defaultSubtree={DEFAULT_MUX}
serialize={(mux) => ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')}
- // protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
- // a host's mux override is protocol-agnostic and should always be editable.
- render={(form) => }
+ /* protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
+ a host's mux override is protocol-agnostic and should always be editable. */
+ render={() => }
/>
);
}
diff --git a/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx b/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx
index 2ee2da85e..0b03d3c69 100644
--- a/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx
+++ b/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx
@@ -4,16 +4,18 @@ import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers';
-// Sockopt override editor — reuses the outbound SockoptForm (which carries its
-// own enable Switch and writes streamSettings.sockopt). Serialized to the host's
-// sockoptParams JSON string.
-//
-// A host is the client/dialer side, so the inbound-only sockopt keys are dropped
-// from the output. Verified against xray-core transport/internet/sockopt_*.go:
-// only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
-// are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
-// on the outbound/dialer socket, so they stay. The outbound form no longer shows
-// the inbound-only keys, but its default object still seeds them, so strip here.
+/*
+ * Sockopt override editor — reuses the outbound SockoptForm (which carries its
+ * own enable Switch and writes streamSettings.sockopt). Serialized to the host's
+ * sockoptParams JSON string.
+ *
+ * A host is the client/dialer side, so the inbound-only sockopt keys are dropped
+ * from the output. Verified against xray-core transport/internet/sockopt_*.go:
+ * only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
+ * are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
+ * on the outbound/dialer socket, so they stay. The outbound form no longer shows
+ * the inbound-only keys, but its default object still seeds them, so strip here.
+ */
const INBOUND_ONLY_SOCKOPT = ['acceptProxyProtocol', 'V6Only', 'trustedXForwardedFor'];
function serializeClientSockopt(sockopt: unknown): string {
@@ -24,11 +26,13 @@ function serializeClientSockopt(sockopt: unknown): string {
}
export default function HostSockoptForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
- // Populate the dialerProxy dropdown with the panel's outbound tags (a host can
- // chain through one of the subscription's outbounds by tag). dialerProxy chains
- // through a single outbound, so balancers (routing targets) are excluded — only
- // the outbound group is used; blackhole is dropped too (chaining to it just
- // drops the traffic).
+ /*
+ * Populate the dialerProxy dropdown with the panel's outbound tags (a host can
+ * chain through one of the subscription's outbounds by tag). dialerProxy chains
+ * through a single outbound, so balancers (routing targets) are excluded — only
+ * the outbound group is used; blackhole is dropped too (chaining to it just
+ * drops the traffic).
+ */
const { data: tagGroups } = useOutboundTagGroups({ excludeBlackhole: true });
const outboundTags = tagGroups?.outbounds ?? [];
return (
@@ -37,7 +41,7 @@ export default function HostSockoptForm({ value, onChange }: { value?: string; o
onChange={onChange}
path={['streamSettings', 'sockopt']}
serialize={serializeClientSockopt}
- render={(form) => }
+ render={() => }
/>
);
}
diff --git a/frontend/src/pages/hosts/json-forms/OutboundSubtreeJsonForm.tsx b/frontend/src/pages/hosts/json-forms/OutboundSubtreeJsonForm.tsx
index 38007f284..fc200fbe0 100644
--- a/frontend/src/pages/hosts/json-forms/OutboundSubtreeJsonForm.tsx
+++ b/frontend/src/pages/hosts/json-forms/OutboundSubtreeJsonForm.tsx
@@ -1,29 +1,32 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
-import { Form, type FormInstance } from 'antd';
-
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
+import { Form } from 'antd';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
+import type { FieldValues } from 'react-hook-form';
import { nestAtPath, parseJsonObject, serializeOverride } from './helpers';
interface OutboundSubtreeJsonFormProps {
value?: string;
onChange?: (next: string) => void;
- // Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux'].
+ /* Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux']. */
path: (string | number)[];
- // Renders the reused outbound form given this wrapper's own form instance.
- render: (form: FormInstance) => ReactNode;
- // Seeds the form when the stored value is empty, so toggling a section on
- // pre-fills sensible defaults instead of blanks (used by Mux).
+ /* Renders the reused outbound form, which binds to this wrapper's RHF context. */
+ render: () => ReactNode;
+ /* Seeds the form when the stored value is empty, so toggling a section on
+ pre-fills sensible defaults instead of blanks (used by Mux). */
defaultSubtree?: Record;
- // Turns the edited subtree into the stored JSON string (default: prune empties).
- // Mux overrides this to store '' (= inherit) when its enable flag is off.
+ /* Turns the edited subtree into the stored JSON string (default: prune empties).
+ Mux overrides this to store '' (= inherit) when its enable flag is off. */
serialize?: (subtree: unknown) => string;
}
-// Hosts the reused outbound transport forms (which bind to fixed form paths)
-// inside an isolated antd Form, mirroring SubJsonFinalMaskForm: seed the form
-// from the JSON string, watch the edited subtree, and report a JSON string back
-// to the parent host form. component={false} avoids a nested DOM node.
+/*
+ * Hosts the reused outbound transport forms (which bind to fixed RHF paths)
+ * inside an isolated RHF form, mirroring the sub-JSON adapters: seed the form
+ * from the JSON string, watch the edited subtree, and report a JSON string back
+ * to the parent host form. The antd Form is layout-only (component={false}
+ * avoids a nested DOM node); data binding runs through the RHF provider.
+ */
export default function OutboundSubtreeJsonForm({
value = '',
onChange,
@@ -32,37 +35,38 @@ export default function OutboundSubtreeJsonForm({
defaultSubtree,
serialize = serializeOverride,
}: OutboundSubtreeJsonFormProps) {
- const [form] = Form.useForm();
const [initial] = useState>(() => {
const parsed = parseJsonObject(value);
return Object.keys(parsed).length ? parsed : (defaultSubtree ?? {});
});
+ const [defaultValues] = useState(() => {
+ const hasInitial = Object.keys(initial).length > 0;
+ return nestAtPath(path, hasInitial ? initial : undefined) as FieldValues;
+ });
+ const methods = useForm({ defaultValues });
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
- const subtree = Form.useWatch(path, form);
+ const subtree = useWatch({ control: methods.control, name: path.join('.') });
useEffect(() => {
const next = serialize(subtree);
if (next !== value) onChangeRef.current?.(next);
- // serialize is logically stable; re-run only when the edited subtree changes.
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ /* serialize is logically stable; re-run only when the edited subtree changes. */
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [subtree, value]);
- const hasInitial = Object.keys(initial).length > 0;
- const initialValues = nestAtPath(path, hasInitial ? initial : undefined);
-
return (
-
- {render(form as unknown as FormInstance)}
-
+
+
+ {render()}
+
+
);
}
diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx
index 23255e761..a7fecd507 100644
--- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx
+++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx
@@ -15,6 +15,7 @@ import {
Tooltip,
message,
} from 'antd';
+import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
import type { RealityScanResult } from '@/generated/types';
@@ -36,7 +37,7 @@ import {
InboundFormSchema,
type InboundFormValues,
} from '@/schemas/forms/inbound-form';
-import { antdRule } from '@/utils/zodForm';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { Protocols } from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
@@ -50,7 +51,7 @@ import { GrpcStreamSettingsSchema } from '@/schemas/protocols/stream/grpc';
import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade';
import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
import { DateTimePicker } from '@/components/form';
-import { FinalMaskForm } from '@/lib/xray/forms/transport';
+import { FinalMaskField } from '@/lib/xray/forms/fields';
import './InboundFormModal.css';
import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
@@ -85,7 +86,7 @@ import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
-// Render a field label with a hover tooltip icon instead of an `extra` help line below.
+/* Render a field label with a hover tooltip icon instead of an `extra` help line below. */
const labelWithHint = (label: string, hint: string) => (
{label}
@@ -162,6 +163,25 @@ function buildAddModeValues(): InboundFormValues {
});
}
+/*
+ * Switching `network` swaps which per-network key (tcpSettings, wsSettings,
+ * grpcSettings, ...) appears on the wire. Seed each network's blob with its
+ * Zod schema defaults so every field inside the network sub-form has a
+ * defined starting value (KCP needs MTU=1350 etc., XHTTP needs the ""
+ * sentinels so the "Default" option shows instead of blank).
+ */
+function newStreamSlice(n: string): Record {
+ switch (n) {
+ case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } });
+ case 'kcp': return KcpStreamSettingsSchema.parse({});
+ case 'ws': return WsStreamSettingsSchema.parse({});
+ case 'grpc': return GrpcStreamSettingsSchema.parse({});
+ case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({});
+ case 'xhttp': return XHttpStreamSettingsSchema.parse({});
+ default: return {};
+ }
+}
+
export default function InboundFormModal({
open,
onClose,
@@ -174,7 +194,10 @@ export default function InboundFormModal({
}: InboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
- const [form] = Form.useForm();
+ const methods = useForm({ defaultValues: buildAddModeValues() });
+ const setV = methods.setValue as unknown as (name: string, value: unknown) => void;
+ const getV = methods.getValues as unknown as (name?: string) => unknown;
+ const control = methods.control;
const [saving, setSaving] = useState(false);
const [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState(null);
@@ -191,41 +214,47 @@ export default function InboundFormModal({
} = useInboundFallbacks(dbInbound, dbInbounds);
const selectableNodes = (availableNodes || []).filter((n) => n.enable);
- const protocol = (Form.useWatch('protocol', form) ?? '') as string;
+ const protocol = (useWatch({ control, name: 'protocol' }) ?? '') as string;
const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
- // The `node` share-address strategy only means something when the inbound can
- // actually live on a node — otherwise the node address it would resolve to is
- // always empty. Offer it only then; `listen`/`custom` work for local inbounds.
+ /*
+ * The `node` share-address strategy only means something when the inbound can
+ * actually live on a node — otherwise the node address it would resolve to is
+ * always empty. Offer it only then; `listen`/`custom` work for local inbounds.
+ */
const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
- const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
- const ssMethod = Form.useWatch(['settings', 'method'], form);
+ const vlessEncryption = useWatch({ control, name: 'settings.encryption' }) ?? '';
+ const ssMethod = useWatch({ control, name: 'settings.method' });
const isSSWith2022 = isSS2022({
protocol,
settings: typeof ssMethod === 'string' ? { method: ssMethod } : {},
});
- const mixedUdpOn = Form.useWatch(['settings', 'udp'], form) ?? false;
- const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
- const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
+ const mixedUdpOn = (useWatch({ control, name: 'settings.udp' }) ?? false) as boolean;
+ const network = (useWatch({ control, name: 'streamSettings.network' }) ?? '') as string;
+ const security = (useWatch({ control, name: 'streamSettings.security' }) ?? 'none') as string;
const streamEnabled = canEnableStream({ protocol });
const sniffingSupported = canEnableSniffing({ protocol });
- // Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no
- // user-selectable transport — their stream tab is just sockopt, which is all
- // Tunnel's TProxy/redirect mode needs (sockopt.tproxy). Hysteria carries its
- // own dedicated transport form. For all of these the RAW/mKCP/WS/... network
- // picker and the per-network sub-forms are hidden.
+ /*
+ * Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no
+ * user-selectable transport — their stream tab is just sockopt, which is all
+ * Tunnel's TProxy/redirect mode needs (sockopt.tproxy). Hysteria carries its
+ * own dedicated transport form. For all of these the RAW/mKCP/WS/... network
+ * picker and the per-network sub-forms are hidden.
+ */
const hasSelectableTransport =
protocol !== Protocols.HYSTERIA
&& protocol !== Protocols.WIREGUARD
&& protocol !== Protocols.TUNNEL;
- const wPort = Form.useWatch('port', form);
- const wListen = (Form.useWatch('listen', form) ?? '') as string;
+ const wPort = useWatch({ control, name: 'port' });
+ const wListen = (useWatch({ control, name: 'listen' }) ?? '') as string;
const isUdsListen = wListen.startsWith('/') || wListen.startsWith('@');
- const wNodeId = Form.useWatch('nodeId', form) ?? null;
- const shareAddrStrategy = Form.useWatch('shareAddrStrategy', form) ?? 'node';
- const wTag = Form.useWatch('tag', form) ?? '';
- const wSsNetwork = Form.useWatch(['settings', 'network'], form);
- const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form);
+ const wNodeId = useWatch({ control, name: 'nodeId' }) ?? null;
+ const shareAddrStrategy = useWatch({ control, name: 'shareAddrStrategy' }) ?? 'node';
+ const wTag = (useWatch({ control, name: 'tag' }) ?? '') as string;
+ const wSsNetwork = useWatch({ control, name: 'settings.network' });
+ const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' });
+ const wTotal = (useWatch({ control, name: 'total' }) as number | undefined) ?? 0;
+ const wExpiry = (useWatch({ control, name: 'expiryTime' }) as number | undefined) ?? 0;
const autoTagRef = useRef(true);
const lastWrittenTagRef = useRef('');
const currentTagInput = (): InboundTagInput => ({
@@ -257,27 +286,24 @@ export default function InboundFormModal({
setCertFromPanel,
clearCertFiles,
onSecurityChange,
- } = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
+ } = useSecurityActions({ methods, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
const toggleSockopt = (on: boolean) => {
if (on) {
- form.setFieldValue(
- ['streamSettings', 'sockopt'],
- SockoptStreamSettingsSchema.parse({}),
- );
+ setV('streamSettings.sockopt', SockoptStreamSettingsSchema.parse({}));
} else {
- form.setFieldValue(['streamSettings', 'sockopt'], undefined);
+ setV('streamSettings.sockopt', undefined);
}
};
- const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form);
+ const wgSecretKey = useWatch({ control, name: 'settings.secretKey' });
const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0
? Wireguard.generateKeypair(wgSecretKey).publicKey
: '';
const regenInboundWg = () => {
const kp = Wireguard.generateKeypair();
- form.setFieldValue(['settings', 'secretKey'], kp.privateKey);
+ setV('settings.secretKey', kp.privateKey);
};
const matchesVlessAuth = (
@@ -306,16 +332,16 @@ export default function InboundFormModal({
};
const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId));
if (!block) return;
- form.setFieldValue(['settings', 'decryption'], block.decryption);
- form.setFieldValue(['settings', 'encryption'], block.encryption);
+ setV('settings.decryption', block.decryption);
+ setV('settings.encryption', block.encryption);
} finally {
setSaving(false);
}
};
const clearVlessEnc = () => {
- form.setFieldValue(['settings', 'decryption'], 'none');
- form.setFieldValue(['settings', 'encryption'], 'none');
+ setV('settings.decryption', 'none');
+ setV('settings.encryption', 'none');
};
const vlessAuthKind = vlessEncryptionAuthKind(
@@ -333,8 +359,7 @@ export default function InboundFormModal({
const initial = mode === 'edit' && dbInbound
? rawInboundToFormValues(dbInbound)
: buildAddModeValues();
- form.resetFields();
- form.setFieldsValue(initial);
+ methods.reset(initial);
setScanResult(null);
const initialTag = (initial.tag ?? '') as string;
autoTagRef.current = isAutoInboundTag(initialTag, {
@@ -355,77 +380,67 @@ export default function InboundFormModal({
loadFallbacks(null);
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, mode, dbInbound, form]);
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
+ }, [open, mode, dbInbound, methods]);
useEffect(() => {
if (!open) return;
if (wTag === lastWrittenTagRef.current) return;
autoTagRef.current = isAutoInboundTag(wTag, currentTagInput());
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [open, wTag]);
useEffect(() => {
if (!open || !autoTagRef.current) return;
const next = composeInboundTag(currentTagInput());
- if (next !== (form.getFieldValue('tag') ?? '')) {
+ if (next !== ((getV('tag') as string | undefined) ?? '')) {
lastWrittenTagRef.current = next;
- form.setFieldValue('tag', next);
+ setV('tag', next);
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
- // Keep the strategy value inside the visible option set: when `node` isn't
- // offered (no node, or a protocol that can't deploy to one) fall back to
- // `listen`, which yields the same link for a local inbound. Mirrors how the
- // protocol reset drops a nodeId that no longer applies.
- // Only downgrade once the inputs this decision depends on are settled, so a
- // persisted `node` strategy is never clobbered by transient mount state (#5375):
- // - `availableNodesFetched`: an empty `availableNodes` during the async
- // /nodes/list fetch is a placeholder, not "no nodes".
- // - `protocol`: `Form.useWatch('protocol')` is briefly empty on the first
- // edit render before initialValues apply, which would momentarily make the
- // node option look unavailable.
+ /*
+ * Keep the strategy value inside the visible option set: when `node` isn't
+ * offered (no node, or a protocol that can't deploy to one) fall back to
+ * `listen`, which yields the same link for a local inbound. Mirrors how the
+ * protocol reset drops a nodeId that no longer applies.
+ * Only downgrade once the inputs this decision depends on are settled, so a
+ * persisted `node` strategy is never clobbered by transient mount state (#5375).
+ */
useEffect(() => {
if (!open) return;
if (!availableNodesFetched || !protocol) return;
- const current = form.getFieldValue('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
+ const current = getV('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
- form.setFieldValue('shareAddrStrategy', 'listen');
+ setV('shareAddrStrategy', 'listen');
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [open, availableNodesFetched, protocol, nodeShareOptionAvailable, shareAddrStrategy]);
- // Why: protocol picker reset cascades through the form — clearing the
- // settings DU branch and dropping a nodeId that no longer applies. The
- // legacy modal did this imperatively in onProtocolChange; here we hook
- // into AntD's onValuesChange and let setFieldValue keep the rest of
- // the form state intact.
- const onValuesChange = (changed: Partial) => {
+ /*
+ * Protocol picker reset cascades through the form — clearing the settings DU
+ * branch and dropping a nodeId that no longer applies. Only a real user
+ * change (type === 'change') triggers it; programmatic setValue (advanced
+ * JSON edits, open reset) must not, matching the legacy onValuesChange.
+ */
+ useEffect(() => {
if (mode === 'edit') return;
- if ('protocol' in changed && typeof changed.protocol === 'string') {
- const next = changed.protocol;
+ /* eslint-disable-next-line react-hooks/incompatible-library */
+ const sub = methods.watch((_value, { name, type }) => {
+ if (name !== 'protocol' || type !== 'change') return;
+ const next = getV('protocol') as string;
const settings = createDefaultInboundSettings(next) ?? undefined;
- form.setFieldValue('settings', settings);
+ setV('settings', settings);
if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
- form.setFieldValue('nodeId', null);
+ setV('nodeId', null);
}
- // Hysteria uses its dedicated transport — force the network branch
- // so the stream tab renders the hysteria sub-form, not the leftover
- // tcpSettings from the previous protocol. When leaving hysteria,
- // snap back to TCP so the standard network selector has a valid
- // starting point.
if (next === Protocols.HYSTERIA) {
- form.setFieldValue('streamSettings', {
+ setV('streamSettings', {
network: 'hysteria',
security: 'tls',
hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
tlsSettings: createHysteriaTlsSettingsWithDefaultCert(),
- // Hysteria2 needs an obfs wrapper on the FinalMask side; seed
- // it with salamander + a random password so the listener boots
- // with a usable default. Re-selecting Hysteria from another
- // protocol re-runs this and refreshes the password — that's
- // intentional, the form was already being reset.
finalmask: {
tcp: [],
udp: [{
@@ -435,37 +450,28 @@ export default function InboundFormModal({
},
});
} else if (next === Protocols.WIREGUARD || next === Protocols.TUNNEL) {
- // Wireguard and Tunnel (dokodemo-door) have no user-selectable
- // transport: wireguard is always a UDP listener, and tunnel only needs
- // `sockopt.tproxy` for its TProxy/redirect mode. Drop the leftover
- // network/transport slices so the stream tab doesn't render a TCP
- // sub-form and the wire payload carries no dead tcpSettings — the
- // sockopt section (with TProxy) stays available.
- form.setFieldValue('streamSettings', { security: 'none' });
+ setV('streamSettings', { security: 'none' });
} else {
- const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
+ const current = getV('streamSettings') as { network?: string } | undefined;
if (current?.network === 'hysteria' || !current?.network) {
- form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
+ setV('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
}
}
- }
- };
+ });
+ return () => sub.unsubscribe();
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
+ }, [mode, methods]);
const submit = async () => {
- try {
- await form.validateFields();
- } catch {
- return;
- }
- // Why getFieldsValue(true) instead of the validateFields return value:
- // rc-component/form's validateFields filters its output by REGISTERED
- // name paths. settings.clients and settings.fallbacks have no Form.Item
- // bound to them (clients are managed via the standalone Client modal,
- // not inside this inbound modal) — so validateFields would drop them
- // and the update wire payload would silently delete every client on
- // every save. getFieldsValue(true) returns the entire form store and
- // keeps those sub-trees intact.
- const values = form.getFieldsValue(true) as InboundFormValues;
+ if (!(await methods.trigger())) return;
+ /*
+ * getValues() returns the entire form store, including settings.clients and
+ * settings.fallbacks which have no bound field (clients are managed via the
+ * standalone Client modal, not this inbound modal). With shouldUnregister
+ * false those pass-through sub-trees survive from the reset object, so the
+ * update wire payload never silently drops every client on save.
+ */
+ const values = methods.getValues() as InboundFormValues;
const parsed = InboundFormSchema.safeParse(values);
if (!parsed.success) {
const issues = parsed.error.issues;
@@ -509,24 +515,16 @@ export default function InboundFormModal({
const basicTab = (
<>
-
-
-
-
-
-
-
-
-
+
-
+
-
+
-
+
{selectableNodes.length > 0 && isNodeEligible && (
-
+
-
+
)}
-
-
-
+
+
+
-
-
+
-
@@ -564,38 +562,35 @@ export default function InboundFormModal({
label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
}))}
/>
-
+
{shareAddrStrategy === 'custom' && (
- (
- isValidShareAddrInput(String(value ?? ''))
- ? Promise.resolve()
- : Promise.reject(new Error(t('pages.inbounds.form.shareAddrHelp')))
- ),
- }]}
+ rules={{
+ validate: (value) =>
+ isValidShareAddrInput(String(value ?? '')) || t('pages.inbounds.form.shareAddrHelp'),
+ }}
>
-
+
)}
-
-
+
-
-
+
}
>
- prev.total !== curr.total}
- >
- {({ getFieldValue, setFieldValue }) => {
- const totalBytes = (getFieldValue('total') as number) ?? 0;
- const totalGB = totalBytes
- ? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100
- : 0;
- return (
- {
- const bytes = NumberFormatter.toFixed(
- (Number(v) || 0) * SizeFormatter.ONE_GB,
- 0,
- );
- setFieldValue('total', bytes);
- }}
- />
- );
+ {
+ const bytes = NumberFormatter.toFixed((Number(v) || 0) * SizeFormatter.ONE_GB, 0);
+ setV('total', bytes);
}}
-
+ />
-
+
({
value: r,
label: t(`pages.inbounds.periodicTrafficReset.${r}`),
}))}
/>
-
+
}
>
- prev.expiryTime !== curr.expiryTime}
- >
- {({ getFieldValue, setFieldValue }) => {
- const expiry = (getFieldValue('expiryTime') as number) ?? 0;
- return (
- 0 ? dayjs(expiry) : null}
- onChange={(d) => setFieldValue('expiryTime', d ? d.valueOf() : 0)}
- />
- );
- }}
-
+ 0 ? dayjs(wExpiry) : null}
+ onChange={(d) => setV('expiryTime', d ? d.valueOf() : 0)}
+ />
>
);
@@ -690,7 +659,7 @@ export default function InboundFormModal({
{protocol === Protocols.MTPROTO && }
- {protocol === Protocols.SHADOWSOCKS && }
+ {protocol === Protocols.SHADOWSOCKS && }
{protocol === Protocols.VLESS && }
@@ -707,41 +676,19 @@ export default function InboundFormModal({
>
);
- // Switching `network` swaps which per-network key (tcpSettings,
- // wsSettings, grpcSettings, ...) appears on the wire. Clear the old
- // network's blob and seed the new one with the schema defaults so the
- // Form.Items inside it have valid initial values (KCP needs MTU=1350
- // etc., not empty strings).
- // Seed each network's settings blob with its Zod schema defaults so
- // every Form.Item inside the network sub-form has a defined starting
- // value. XHTTP in particular has ~20 fields (sessionIDPlacement,
- // seqPlacement, xPaddingMethod, uplinkHTTPMethod, ...) whose value
- // is the literal "" sentinel meaning "let xray-core pick its
- // default". Without seeding "", the Form.Item reads `undefined` and
- // the Select shows blank instead of the "Default (path)" option.
- const newStreamSlice = (n: string): Record => {
- switch (n) {
- case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } });
- case 'kcp': return KcpStreamSettingsSchema.parse({});
- case 'ws': return WsStreamSettingsSchema.parse({});
- case 'grpc': return GrpcStreamSettingsSchema.parse({});
- case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({});
- case 'xhttp': return XHttpStreamSettingsSchema.parse({});
- default: return {};
- }
- };
+ /*
+ * Switching `network` swaps which per-network key appears on the wire. Clear
+ * the old network's blob and seed the new one with schema defaults, plus the
+ * FinalMask mkcp-legacy UDP mask when moving to mKCP (removed otherwise).
+ */
const onNetworkChange = (next: string) => {
const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings'];
- const current = (form.getFieldValue('streamSettings') as Record) ?? {};
+ const current = (getV('streamSettings') as Record) ?? {};
const cleaned: Record = { ...current, network: next };
for (const k of ALL) {
if (k !== `${next}Settings`) delete cleaned[k];
}
cleaned[`${next}Settings`] = newStreamSlice(next);
- // mKCP wants a UDP mask wrapper on the FinalMask side; seed it with
- // `mkcp-legacy` so the inbound boots with a sensible default
- // instead of unobfuscated mKCP traffic. The user can still edit or
- // clear the mask via the FinalMask section.
if (next === 'kcp') {
const fm = (cleaned.finalmask as Record | undefined) ?? {};
const udp = Array.isArray(fm.udp) ? (fm.udp as unknown[]) : [];
@@ -762,15 +709,16 @@ export default function InboundFormModal({
cleaned.finalmask = { ...fm, udp };
}
}
- form.setFieldValue('streamSettings', cleaned);
+ setV('streamSettings', cleaned);
};
const streamTab = (
<>
{hasSelectableTransport && (
-
+
}
+ dropdown is hidden above. */}
+ {protocol === Protocols.HYSTERIA && }
{hasSelectableTransport && (
<>
@@ -801,7 +745,7 @@ export default function InboundFormModal({
{network === 'grpc' && }
- {network === 'xhttp' && }
+ {network === 'xhttp' && }
{network === 'httpupgrade' && }
@@ -813,56 +757,45 @@ export default function InboundFormModal({
field is still parsed/rendered for backward compatibility but is no
longer editable here. */}
-
+
{/* Transport masks don't apply to tunnel (a transparent forwarder), so
its stream tab is just sockopt + TProxy. */}
{protocol !== Protocols.TUNNEL && (
- (
+
+ )}
/>
)}
>
);
+ const tlsOk = canEnableTls({ protocol, streamSettings: { network, security } });
+ const realityOk = canEnableReality({ protocol, streamSettings: { network, security } });
+ const tlsOnly = protocol === Protocols.HYSTERIA;
+
const securityTab = (
<>
-
-
-
-
- prev.streamSettings?.security !== curr.streamSettings?.security
- || prev.streamSettings?.network !== curr.streamSettings?.network
- || prev.protocol !== curr.protocol
- }
+ onSecurityChange(e.target.value)}
>
- {({ getFieldValue }) => {
- const sec = getFieldValue(['streamSettings', 'security']) ?? 'none';
- const net = getFieldValue(['streamSettings', 'network']) ?? '';
- const proto = getFieldValue('protocol') ?? '';
- const tlsOk = canEnableTls({ protocol: proto, streamSettings: { network: net, security: sec } });
- const realityOk = canEnableReality({ protocol: proto, streamSettings: { network: net, security: sec } });
- const tlsOnly = proto === Protocols.HYSTERIA;
- return (
- onSecurityChange(e.target.value)}
- >
- {!tlsOnly && {t('none')} }
- TLS
- {realityOk && Reality }
-
- );
- }}
-
+ {!tlsOnly && {t('none')} }
+ TLS
+ {realityOk && Reality }
+
{security === 'tls' && (
@@ -916,7 +849,7 @@ export default function InboundFormModal({
{t('pages.inbounds.advanced.allHelp')}
-
+
>
),
},
@@ -930,7 +863,6 @@ export default function InboundFormModal({
{'{ settings: { ... } }'}.
{'{ streamSettings: { ... } }'}.
{'{ sniffing: { ... } }'}.
-
-
-
+
+
+
+
+
>
);
diff --git a/frontend/src/pages/inbounds/form/SniffingTab.tsx b/frontend/src/pages/inbounds/form/SniffingTab.tsx
index ad94fb957..9bc502590 100644
--- a/frontend/src/pages/inbounds/form/SniffingTab.tsx
+++ b/frontend/src/pages/inbounds/form/SniffingTab.tsx
@@ -1,16 +1,22 @@
import { useTranslation } from 'react-i18next';
-import { Form } from 'antd';
+import { Controller, useFormContext } from 'react-hook-form';
-import SniffingFields from '@/lib/xray/forms/SniffingFields';
+import { SniffingField } from '@/lib/xray/forms/fields';
export default function SniffingTab() {
const { t } = useTranslation();
- const form = Form.useFormInstance();
+ const { control } = useFormContext();
return (
- (
+
+ )}
/>
);
}
diff --git a/frontend/src/pages/inbounds/form/advanced-editors.tsx b/frontend/src/pages/inbounds/form/advanced-editors.tsx
index 788fc7ac1..b19a9b691 100644
--- a/frontend/src/pages/inbounds/form/advanced-editors.tsx
+++ b/frontend/src/pages/inbounds/form/advanced-editors.tsx
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react';
-import { Form, type FormInstance } from 'antd';
-import type { NamePath } from 'antd/es/form/interface';
+import { useFormContext, useWatch } from 'react-hook-form';
import { JsonEditor } from '@/components/form';
import {
@@ -9,46 +8,44 @@ import {
normalizeClients,
dropLegacyOptionalEmpties,
} from '@/lib/xray/inbound-form-adapter';
-import type { InboundFormValues } from '@/schemas/forms/inbound-form';
-// Sub-editor for one slice of the form (settings, streamSettings, sniffing).
-// Holds a local text buffer so the user can type freely; on every keystroke
-// we try to JSON.parse and forward the result to form state. Invalid JSON
-// is held in the buffer until the next valid moment — no panic on partial
-// input. The buffer seeds once on mount; the modal's destroyOnHidden makes
-// each open a fresh editor instance, so we don't need to re-sync on outer
-// form changes.
+/*
+ * Sub-editor for one slice of the form (settings, streamSettings, sniffing).
+ * Holds a local text buffer so the user can type freely; on every keystroke
+ * we try to JSON.parse and forward the result to form state. Invalid JSON
+ * is held in the buffer until the next valid moment — no panic on partial
+ * input. The buffer seeds once on mount; the modal's destroyOnHidden makes
+ * each open a fresh editor instance, so we don't need to re-sync on outer
+ * form changes.
+ */
export function AdvancedSliceEditor({
- form,
path,
wrapKey,
minHeight,
maxHeight,
}: {
- form: FormInstance;
- path: NamePath;
- // When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so
- // the JSON the user sees matches the wire shape's slice envelope (e.g.
- // `{ "settings": { ... } }`). Edits unwrap the outer key before writing
- // back to the form. Mirrors the legacy modal's wrappedConfigValue.
+ path: string;
+ /*
+ * When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so
+ * the JSON the user sees matches the wire shape's slice envelope (e.g.
+ * `{ "settings": { ... } }`). Edits unwrap the outer key before writing
+ * back to the form. Mirrors the legacy modal's wrappedConfigValue.
+ */
wrapKey?: string;
minHeight?: string;
maxHeight?: string;
}) {
+ const { control, getValues, setValue } = useFormContext();
+
const serialize = (value: unknown): string => {
const inner = value ?? {};
return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2);
};
- // preserve: true so useWatch returns the full subtree from the form
- // store — without it, useWatch goes through getFieldsValue() which
- // filters out unregistered fields. Slices like `settings` would lose
- // their `clients` / `fallbacks` sub-trees because those aren't bound
- // to any Form.Item.
- const watched = Form.useWatch(path, { form, preserve: true });
+ const watched = useWatch({ control, name: path });
const lastEmitRef = useRef('');
const [text, setText] = useState(() => {
- const initial = serialize(form.getFieldValue(path));
+ const initial = serialize(getValues(path));
lastEmitRef.current = initial;
return initial;
});
@@ -58,7 +55,7 @@ export function AdvancedSliceEditor({
if (formStr === lastEmitRef.current) return;
setText(formStr);
lastEmitRef.current = formStr;
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [watched, wrapKey]);
return (
@@ -73,48 +70,47 @@ export function AdvancedSliceEditor({
const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record)[wrapKey] ?? {}
: parsed;
- form.setFieldValue(path, toWrite);
+ setValue(path, toWrite);
lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2);
} catch {
- // invalid JSON; keep buffer, don't push to form
+ /* invalid JSON; keep buffer, don't push to form */
}
}}
/>
);
}
-// The "All" editor shows the full inbound JSON in one editor: top-level
-// connection fields plus the three nested sub-objects (settings,
-// streamSettings, sniffing). Edits round-trip back to the form's slices,
-// mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity
-// works the same way as AdvancedSliceEditor: useWatch on the slices we
-// care about, lastEmitRef as the "we wrote this" guard.
+/*
+ * The "All" editor shows the full inbound JSON in one editor: top-level
+ * connection fields plus the three nested sub-objects (settings,
+ * streamSettings, sniffing). Edits round-trip back to the form's slices,
+ * mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity
+ * works the same way as AdvancedSliceEditor: useWatch on the slices we
+ * care about, lastEmitRef as the "we wrote this" guard.
+ */
export function AdvancedAllEditor({
- form,
streamEnabled,
sniffingEnabled,
}: {
- form: FormInstance;
streamEnabled: boolean;
sniffingEnabled: boolean;
}) {
- // preserve: true — default useWatch returns only registered fields, so
- // sub-trees we never bound (settings.clients/fallbacks, sniffing
- // defaults, etc.) wouldn't show up. preserve switches the read to
- // getFieldsValue(true) which returns the full form store.
- const wListen = Form.useWatch('listen', { form, preserve: true });
- const wPort = Form.useWatch('port', { form, preserve: true });
- const wProtocol = Form.useWatch('protocol', { form, preserve: true });
- const wTag = Form.useWatch('tag', { form, preserve: true });
- const wSettings = Form.useWatch('settings', { form, preserve: true });
- const wSniffing = Form.useWatch('sniffing', { form, preserve: true });
- const wStream = Form.useWatch('streamSettings', { form, preserve: true });
+ const { control, setValue } = useFormContext();
+ const wListen = useWatch({ control, name: 'listen' });
+ const wPort = useWatch({ control, name: 'port' });
+ const wProtocol = useWatch({ control, name: 'protocol' });
+ const wTag = useWatch({ control, name: 'tag' });
+ const wSettings = useWatch({ control, name: 'settings' });
+ const wSniffing = useWatch({ control, name: 'sniffing' });
+ const wStream = useWatch({ control, name: 'streamSettings' });
const serialize = () => {
- // Apply the same prune/normalize as the wire payload so the JSON
- // shown here is what the panel actually POSTs (no empty defaults,
- // disabled sniffing as { enabled: false }, finalmask dropped when
- // there are no masks).
+ /*
+ * Apply the same prune/normalize as the wire payload so the JSON
+ * shown here is what the panel actually POSTs (no empty defaults,
+ * disabled sniffing as { enabled: false }, finalmask dropped when
+ * there are no masks).
+ */
const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record;
if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
@@ -149,7 +145,7 @@ export function AdvancedAllEditor({
if (formStr === lastEmitRef.current) return;
setText(formStr);
lastEmitRef.current = formStr;
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled, sniffingEnabled]);
return (
@@ -166,20 +162,20 @@ export function AdvancedAllEditor({
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
- if (typeof parsed.listen === 'string') form.setFieldValue('listen', parsed.listen);
+ if (typeof parsed.listen === 'string') setValue('listen', parsed.listen);
if (typeof parsed.port === 'number' && Number.isFinite(parsed.port)) {
- form.setFieldValue('port', parsed.port);
+ setValue('port', parsed.port);
}
- if (typeof parsed.protocol === 'string') form.setFieldValue('protocol', parsed.protocol);
- if (typeof parsed.tag === 'string') form.setFieldValue('tag', parsed.tag);
+ if (typeof parsed.protocol === 'string') setValue('protocol', parsed.protocol);
+ if (typeof parsed.tag === 'string') setValue('tag', parsed.tag);
if (parsed.settings && typeof parsed.settings === 'object') {
- form.setFieldValue('settings', parsed.settings);
+ setValue('settings', parsed.settings);
}
if (sniffingEnabled && parsed.sniffing && typeof parsed.sniffing === 'object') {
- form.setFieldValue('sniffing', parsed.sniffing);
+ setValue('sniffing', parsed.sniffing);
}
if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') {
- form.setFieldValue('streamSettings', parsed.streamSettings);
+ setValue('streamSettings', parsed.streamSettings);
}
lastEmitRef.current = next;
}}
diff --git a/frontend/src/pages/inbounds/form/protocols/accounts-list.tsx b/frontend/src/pages/inbounds/form/protocols/accounts-list.tsx
index 2367545ab..4d6770b75 100644
--- a/frontend/src/pages/inbounds/form/protocols/accounts-list.tsx
+++ b/frontend/src/pages/inbounds/form/protocols/accounts-list.tsx
@@ -1,47 +1,47 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Space } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext } from 'react-hook-form';
import { RandomUtil } from '@/utils';
import { InputAddon } from '@/components/ui';
+import { FormField } from '@/components/form/rhf';
export default function AccountsList() {
const { t } = useTranslation();
+ const { control } = useFormContext();
+ const { fields, append, remove } = useFieldArray({ control, name: 'settings.accounts' });
return (
-
- {(fields, { add, remove }) => (
- <>
-
- add({
- user: RandomUtil.randomLowerAndNum(8),
- pass: RandomUtil.randomLowerAndNum(12),
- })}
- >
- {t('add')}
-
-
- {fields.length > 0 && (
-
- {fields.map((field, idx) => (
-
- {String(idx + 1)}
-
-
-
-
-
-
- remove(field.name)}>
-
-
-
- ))}
-
- )}
- >
+ <>
+
+ append({
+ user: RandomUtil.randomLowerAndNum(8),
+ pass: RandomUtil.randomLowerAndNum(12),
+ })}
+ >
+ {t('add')}
+
+
+ {fields.length > 0 && (
+
+ {fields.map((field, idx) => (
+
+ {String(idx + 1)}
+
+
+
+
+
+
+ remove(idx)}>
+
+
+
+ ))}
+
)}
-
+ >
);
}
diff --git a/frontend/src/pages/inbounds/form/protocols/http.tsx b/frontend/src/pages/inbounds/form/protocols/http.tsx
index 11806fae9..9ad5382a6 100644
--- a/frontend/src/pages/inbounds/form/protocols/http.tsx
+++ b/frontend/src/pages/inbounds/form/protocols/http.tsx
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
-import { Form, Switch } from 'antd';
+import { Switch } from 'antd';
+import { FormField } from '@/components/form/rhf';
import AccountsList from './accounts-list';
export default function HttpFields() {
@@ -8,13 +9,13 @@ export default function HttpFields() {
return (
<>
-
-
+
>
);
}
diff --git a/frontend/src/pages/inbounds/form/protocols/hysteria.tsx b/frontend/src/pages/inbounds/form/protocols/hysteria.tsx
index 97845d115..343a084fb 100644
--- a/frontend/src/pages/inbounds/form/protocols/hysteria.tsx
+++ b/frontend/src/pages/inbounds/form/protocols/hysteria.tsx
@@ -1,128 +1,124 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
-export default function HysteriaFields({ form }: { form: FormInstance }) {
+export default function HysteriaFields() {
const { t } = useTranslation();
+ const { control, setValue } = useFormContext();
+ const masq = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade' }) as
+ | { type?: string }
+ | undefined;
+ const masqType = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade.type' }) as
+ | string
+ | undefined;
return (
<>
-
-
-
+
-
+
-
- {() => {
- const m = form.getFieldValue(MASQ_PATH);
- return (
-
- form.setFieldValue(
- MASQ_PATH,
- checked
- ? {
- type: '', dir: '', url: '',
- rewriteHost: false, insecure: false,
- content: '', headers: {}, statusCode: 0,
- }
- : undefined,
- )
+
+ setValue(
+ 'streamSettings.hysteriaSettings.masquerade',
+ checked
+ ? {
+ type: '', dir: '', url: '',
+ rewriteHost: false, insecure: false,
+ content: '', headers: {}, statusCode: 0,
}
- />
- );
- }}
-
+ : undefined,
+ )
+ }
+ />
-
- {() => {
- const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
- if (!m) return null;
- return (
+ {masq && (
+ <>
+
+
+
+ {masqType === 'proxy' && (
<>
-
-
-
- {m.type === 'proxy' && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
- {m.type === 'file' && (
-
-
-
- )}
- {m.type === 'string' && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
+
+
+
+
+
+
+
+
>
- );
- }}
-
+ )}
+ {masqType === 'file' && (
+
+
+
+ )}
+ {masqType === 'string' && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ )}
>
);
}
diff --git a/frontend/src/pages/inbounds/form/protocols/mixed.tsx b/frontend/src/pages/inbounds/form/protocols/mixed.tsx
index 274aa03d4..80fce4651 100644
--- a/frontend/src/pages/inbounds/form/protocols/mixed.tsx
+++ b/frontend/src/pages/inbounds/form/protocols/mixed.tsx
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, Select, Switch } from 'antd';
+import { Input, Select, Switch } from 'antd';
+import { FormField } from '@/components/form/rhf';
import AccountsList from './accounts-list';
export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
@@ -8,25 +9,25 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
return (
<>
-
+
-
-
+
-
+
{mixedUdpOn && (
-
+
-
+
)}
>
);
diff --git a/frontend/src/pages/inbounds/form/protocols/mtproto.tsx b/frontend/src/pages/inbounds/form/protocols/mtproto.tsx
index 72ce53031..b61bb27f2 100644
--- a/frontend/src/pages/inbounds/form/protocols/mtproto.tsx
+++ b/frontend/src/pages/inbounds/form/protocols/mtproto.tsx
@@ -1,47 +1,49 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { Input, InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
+import { FormField } from '@/components/form/rhf';
import { useOutboundTags } from '@/api/queries/useOutboundTags';
export default function MtprotoFields() {
const { t } = useTranslation();
- const form = Form.useFormInstance();
- const routeThroughXray = Form.useWatch(['settings', 'routeThroughXray'], form) as boolean | undefined;
+ const { control } = useFormContext();
+ const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as boolean | undefined;
const { data: outboundTags } = useOutboundTags();
return (
<>
-
-
-
+
-
-
+
+
-
-
+
-
-
+
-
-
+
+
-
-
+
+
-
-
+
-
-
+
-
+
{routeThroughXray && (
- ({ value: tag, label: tag }))}
/>
-
+
)}
-
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx b/frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx
index 2b722455b..0a2ffda30 100644
--- a/frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx
+++ b/frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx
@@ -1,44 +1,45 @@
import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, Select, Space, Switch, type FormInstance } from 'antd';
+import { Button, Form, Input, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
+import { useFormContext } from 'react-hook-form';
import { RandomUtil } from '@/utils';
+import { FormField } from '@/components/form/rhf';
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
-import type { InboundFormValues } from '@/schemas/forms/inbound-form';
interface ShadowsocksFieldsProps {
- form: FormInstance;
isSSWith2022: boolean;
}
-export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFieldsProps) {
+export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsProps) {
const { t } = useTranslation();
+ const { getValues, setValue } = useFormContext();
return (
<>
-
+ {
+ setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
+ }}
+ >
{
- form.setFieldValue(
- ['settings', 'password'],
- RandomUtil.randomShadowsocksPassword(v as string),
- );
- }}
options={SSMethodSchema.options.map((m) => ({ value: m, label: m }))}
/>
-
+
{isSSWith2022 && (
-
+
-
+
}
onClick={() => {
- const method = form.getFieldValue(['settings', 'method']);
- form.setFieldValue(
- ['settings', 'password'],
+ const method = getValues('settings.method');
+ setValue(
+ 'settings.password',
RandomUtil.randomShadowsocksPassword(method as string),
);
}}
@@ -46,7 +47,7 @@ export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFie
)}
-
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/inbounds/form/protocols/tun.tsx b/frontend/src/pages/inbounds/form/protocols/tun.tsx
index 952aef02a..2a92b3971 100644
--- a/frontend/src/pages/inbounds/form/protocols/tun.tsx
+++ b/frontend/src/pages/inbounds/form/protocols/tun.tsx
@@ -1,84 +1,73 @@
+import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Space, Tooltip } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext } from 'react-hook-form';
+
+import { FormField } from '@/components/form/rhf';
+
+interface StringListProps {
+ name: string[];
+ label: ReactNode;
+ placeholder: (index: number) => string;
+}
+
+function StringList({ name, label, placeholder }: StringListProps) {
+ const { t } = useTranslation();
+ const { control } = useFormContext();
+ const { fields, append, remove } = useFieldArray({ control, name: name.join('.') });
+ return (
+
+ append('')}>
+
+
+ {fields.map((field, j) => (
+
+
+
+
+ remove(j)}>
+
+
+
+ ))}
+
+ );
+}
export default function TunFields() {
const { t } = useTranslation();
return (
<>
-
+
-
-
+
+
-
-
- {(fields, { add, remove }) => (
-
- add('')}>
-
-
- {fields.map((field, j) => (
-
-
-
-
- remove(field.name)}>
-
-
-
- ))}
-
- )}
-
-
- {(fields, { add, remove }) => (
-
- add('')}>
-
-
- {fields.map((field, j) => (
-
-
-
-
- remove(field.name)}>
-
-
-
- ))}
-
- )}
-
-
+
+ (j === 0 ? '10.0.0.1/16' : 'fc00::1/64')}
+ />
+ (j === 0 ? '1.1.1.1' : '8.8.8.8')}
+ />
+
-
-
- {(fields, { add, remove }) => (
-
- {t('pages.inbounds.info.autoSystemRoutes')}
-
- }
- >
- add('')}>
-
-
- {fields.map((field, j) => (
-
-
-
-
- remove(field.name)}>
-
-
-
- ))}
-
- )}
-
-
+
+ {t('pages.inbounds.info.autoSystemRoutes')}
+
+ }
+ placeholder={(j) => (j === 0 ? '0.0.0.0/0' : '::/0')}
+ />
+
@@ -87,7 +76,7 @@ export default function TunFields() {
}
>
-
+
>
);
}
diff --git a/frontend/src/pages/inbounds/form/protocols/tunnel.tsx b/frontend/src/pages/inbounds/form/protocols/tunnel.tsx
index 5cfbec155..ea44922aa 100644
--- a/frontend/src/pages/inbounds/form/protocols/tunnel.tsx
+++ b/frontend/src/pages/inbounds/form/protocols/tunnel.tsx
@@ -1,19 +1,20 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { Input, InputNumber, Select, Switch } from 'antd';
import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
export default function TunnelFields() {
const { t } = useTranslation();
return (
<>
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/inbounds/form/protocols/vless.tsx b/frontend/src/pages/inbounds/form/protocols/vless.tsx
index 85c029b34..78c3428c9 100644
--- a/frontend/src/pages/inbounds/form/protocols/vless.tsx
+++ b/frontend/src/pages/inbounds/form/protocols/vless.tsx
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
+import { Controller, useFormContext } from 'react-hook-form';
+import { FormField } from '@/components/form/rhf';
import { VLESS_AUTH_LABEL_KEYS, type VlessAuthKind } from '@/lib/xray/vless-encryption';
interface VlessFieldsProps {
@@ -24,6 +26,7 @@ export default function VlessFields({
clearVlessEnc,
}: VlessFieldsProps) {
const { t } = useTranslation();
+ const { control } = useFormContext();
const [authKind, setAuthKind] = useState(vlessAuthKind ?? 'x25519');
useEffect(() => {
@@ -36,12 +39,12 @@ export default function VlessFields({
return (
<>
-
+
-
-
+
+
-
+
{[900, 500, 900, 256].map((def, i) => (
-
-
-
+ (
+
+ )}
+ />
))}
diff --git a/frontend/src/pages/inbounds/form/protocols/wireguard.tsx b/frontend/src/pages/inbounds/form/protocols/wireguard.tsx
index 9d72b5a7e..595a5bfb4 100644
--- a/frontend/src/pages/inbounds/form/protocols/wireguard.tsx
+++ b/frontend/src/pages/inbounds/form/protocols/wireguard.tsx
@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
+import { FormField } from '@/components/form/rhf';
+
interface WireguardFieldsProps {
wgPubKey: string;
regenInboundWg: () => void;
@@ -13,29 +15,29 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
<>
-
+
-
+
} onClick={regenInboundWg} />
-
+
-
-
+
+
-
-
+
-
-
+
+
-
+
>
);
}
diff --git a/frontend/src/pages/inbounds/form/security/reality.tsx b/frontend/src/pages/inbounds/form/security/reality.tsx
index bbf61e9c8..063fba69d 100644
--- a/frontend/src/pages/inbounds/form/security/reality.tsx
+++ b/frontend/src/pages/inbounds/form/security/reality.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
+import { FormField } from '@/components/form/rhf';
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
import type { RealityScanResult } from '@/generated/types';
@@ -41,43 +42,41 @@ export default function RealityForm({
const [scannerOpen, setScannerOpen] = useState(false);
return (
<>
-
-
-
+
+
-
-
+
({ value: fp, label: fp }))}
/>
-
+
- {
- const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
- if (errKey) throw new Error(t(errKey));
- },
+ rules={{
+ validate: (value) => {
+ const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
+ return errKey ? errKey : true;
},
- ]}
+ }}
>
-
+
} loading={scanning} onClick={scanRealityTarget}>
{t('pages.inbounds.form.scan')}
@@ -116,35 +115,35 @@ export default function RealityForm({
/>
)}
-
+
-
-
+
-
-
+
-
-
+
-
+
-
-
+
} onClick={randomizeShortIds} />
@@ -153,27 +152,27 @@ export default function RealityForm({
tooltip={t('pages.inbounds.form.spiderXHint')}
>
-
-
+
} onClick={randomizeSpiderX} />
-
-
-
+
-
+
@@ -182,18 +181,18 @@ export default function RealityForm({
{t('clear')}
-
-
-
+
-
+
@@ -202,13 +201,13 @@ export default function RealityForm({
{t('clear')}
-
-
+
{t(`pages.inbounds.form.${dir}`)}
-
-
-
+
-
-
+
-
+
))}
>
diff --git a/frontend/src/pages/inbounds/form/security/tls.tsx b/frontend/src/pages/inbounds/form/security/tls.tsx
index cbbd2f316..250ed6a33 100644
--- a/frontend/src/pages/inbounds/form/security/tls.tsx
+++ b/frontend/src/pages/inbounds/form/security/tls.tsx
@@ -1,7 +1,9 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
import { CloudDownloadOutlined, FileProtectOutlined, MinusOutlined, PlusOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
+import { FormField } from '@/components/form/rhf';
import {
ALPN_OPTION,
DOMAIN_STRATEGY_OPTION,
@@ -14,6 +16,11 @@ import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'
const { TextArea } = Input;
+const CERT_LINES_TRANSFORM = {
+ input: (v: unknown) => (Array.isArray(v) ? v.join('\n') : v),
+ output: (raw: unknown) => (typeof raw === 'string' ? raw.split('\n') : raw),
+};
+
interface TlsFormProps {
saving: boolean;
setCertFromPanel: (certName: number) => void;
@@ -24,6 +31,178 @@ interface TlsFormProps {
clearEchCert: () => void;
}
+interface CertRowProps {
+ index: number;
+ total: number;
+ saving: boolean;
+ onRemove: () => void;
+ setCertFromPanel: (certName: number) => void;
+ clearCertFiles: (certName: number) => void;
+}
+
+function CertRow({ index, total, saving, onRemove, setCertFromPanel, clearCertFiles }: CertRowProps) {
+ const { t } = useTranslation();
+ const { control } = useFormContext();
+ const useFile = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.useFile` });
+ const usage = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.usage` });
+ return (
+
+
+
+
+ {t('pages.inbounds.certificatePath')}
+
+
+ {t('pages.inbounds.certificateContent')}
+
+
+
+ {total > 1 && (
+
+
+ {t('remove')}
+
+
+ )}
+ {useFile ? (
+ <>
+
+
+
+
+
+
+
+
+ setCertFromPanel(index)}
+ >
+ {t('pages.inbounds.setDefaultCert')}
+
+ clearCertFiles(index)}>
+ {t('clear')}
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ ({ value: u, label: u }))}
+ />
+
+ {usage === 'issue' && (
+
+
+
+ )}
+
+ );
+}
+
+function EchSockoptSection() {
+ const { t } = useTranslation();
+ const { control, setValue } = useFormContext();
+ const echSockopt = useWatch({ control, name: 'streamSettings.tlsSettings.echSockopt' });
+ const on = !!echSockopt;
+ return (
+ <>
+
+
+ setValue(
+ 'streamSettings.tlsSettings.echSockopt',
+ v ? SockoptStreamSettingsSchema.parse({}) : undefined,
+ )
+ }
+ />
+
+ {on && (
+ <>
+
+
+
+
+ ({ value: v, label: v }))}
+ />
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ );
+}
+
export default function TlsForm({
saving,
setCertFromPanel,
@@ -34,36 +213,41 @@ export default function TlsForm({
clearEchCert,
}: TlsFormProps) {
const { t } = useTranslation();
+ const { control } = useFormContext();
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: 'streamSettings.tlsSettings.certificates',
+ });
return (
<>
-
+
-
-
+
+
({ value: v, label: k })),
]}
/>
-
+
-
+
({ value: v, label: v }))}
/>
-
-
+
+
({ value: v, label: v }))}
/>
-
+
-
@@ -73,16 +257,16 @@ export default function TlsForm({
...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
]}
/>
-
-
+
+
({ value: a, label: a }))}
/>
-
-
+
-
-
+
-
-
+
-
-
+
-
+
-
- {(certFields, { add, remove }) => (
- <>
-
- add({
- useFile: true,
- certificateFile: '',
- keyFile: '',
- certificate: [],
- key: [],
- ocspStapling: 0,
- oneTimeLoading: false,
- usage: 'encipherment',
- buildChain: false,
- })}
- >
-
-
-
- {certFields.map((certField, idx) => (
-
-
-
-
- {t('pages.inbounds.certificatePath')}
-
-
- {t('pages.inbounds.certificateContent')}
-
-
-
- {certFields.length > 1 && (
-
- remove(certField.name)}
- >
- {t('remove')}
-
-
- )}
-
- prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
- !== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
- }
- >
- {({ getFieldValue }) => {
- const useFile = getFieldValue([
- 'streamSettings', 'tlsSettings', 'certificates',
- certField.name, 'useFile',
- ]);
- return useFile ? (
- <>
-
-
-
-
-
-
-
-
- setCertFromPanel(certField.name)}
- >
- {t('pages.inbounds.setDefaultCert')}
-
- clearCertFiles(certField.name)}>
- {t('clear')}
-
-
-
- >
- ) : (
- <>
- typeof v === 'string'
- ? v.split('\n')
- : v}
- getValueProps={(v) => ({
- value: Array.isArray(v) ? v.join('\n') : v,
- })}
- >
-
-
- typeof v === 'string'
- ? v.split('\n')
- : v}
- getValueProps={(v) => ({
- value: Array.isArray(v) ? v.join('\n') : v,
- })}
- >
-
-
- >
- );
- }}
-
-
-
-
-
-
-
-
- ({ value: u, label: u }))}
- />
-
-
- prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
- !== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
- }
- >
- {({ getFieldValue }) => {
- const usage = getFieldValue([
- 'streamSettings', 'tlsSettings', 'certificates',
- certField.name, 'usage',
- ]);
- if (usage !== 'issue') return null;
- return (
-
-
-
- );
- }}
-
-
- ))}
- >
- )}
-
-
+ append({
+ useFile: true,
+ certificateFile: '',
+ keyFile: '',
+ certificate: [],
+ key: [],
+ ocspStapling: 0,
+ oneTimeLoading: false,
+ usage: 'encipherment',
+ buildChain: false,
+ })}
+ >
+
+
+
+ {fields.map((field, idx) => (
+ remove(idx)}
+ setCertFromPanel={setCertFromPanel}
+ clearCertFiles={clearCertFiles}
+ />
+ ))}
+
-
-
- !!(prev.streamSettings as { tlsSettings?: { echSockopt?: unknown } } | undefined)?.tlsSettings?.echSockopt
- !== !!(curr.streamSettings as { tlsSettings?: { echSockopt?: unknown } } | undefined)?.tlsSettings?.echSockopt
- }
- >
- {({ getFieldValue, setFieldValue }) => {
- const on = !!getFieldValue(['streamSettings', 'tlsSettings', 'echSockopt']);
- return (
- <>
-
-
- setFieldValue(
- ['streamSettings', 'tlsSettings', 'echSockopt'],
- v ? SockoptStreamSettingsSchema.parse({}) : undefined,
- )
- }
- />
-
- {on && (
- <>
-
-
-
-
- ({ value: v, label: v }))}
- />
-
-
-
-
-
-
-
- >
- )}
- >
- );
- }}
-
-
+
+
+
-
-
+
-
+
@@ -377,7 +364,7 @@ export default function TlsForm({
tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
>
-
@@ -387,7 +374,7 @@ export default function TlsForm({
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
style={{ width: 'calc(100% - 64px)' }}
/>
-
+
}
onClick={pinFromCert}
@@ -402,13 +389,13 @@ export default function TlsForm({
/>
-
-
+
>
);
}
diff --git a/frontend/src/pages/inbounds/form/transport/grpc.tsx b/frontend/src/pages/inbounds/form/transport/grpc.tsx
index b6bc48120..15bb797aa 100644
--- a/frontend/src/pages/inbounds/form/transport/grpc.tsx
+++ b/frontend/src/pages/inbounds/form/transport/grpc.tsx
@@ -1,29 +1,31 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, Switch } from 'antd';
+import { Input, Switch } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
export default function GrpcForm() {
const { t } = useTranslation();
return (
<>
-
-
-
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/inbounds/form/transport/httpupgrade.tsx b/frontend/src/pages/inbounds/form/transport/httpupgrade.tsx
index cb56e1adb..c205dbd86 100644
--- a/frontend/src/pages/inbounds/form/transport/httpupgrade.tsx
+++ b/frontend/src/pages/inbounds/form/transport/httpupgrade.tsx
@@ -1,37 +1,38 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, Switch } from 'antd';
+import { Input, Switch } from 'antd';
import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
export default function HttpUpgradeForm() {
const { t } = useTranslation();
return (
<>
-
-
-
+
-
-
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/inbounds/form/transport/kcp.tsx b/frontend/src/pages/inbounds/form/transport/kcp.tsx
index e688cd508..b3a328925 100644
--- a/frontend/src/pages/inbounds/form/transport/kcp.tsx
+++ b/frontend/src/pages/inbounds/form/transport/kcp.tsx
@@ -1,34 +1,36 @@
import { useTranslation } from 'react-i18next';
-import { Form, InputNumber } from 'antd';
+import { InputNumber } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
export default function KcpForm() {
const { t } = useTranslation();
return (
<>
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/inbounds/form/transport/raw.tsx b/frontend/src/pages/inbounds/form/transport/raw.tsx
index f8d44d6db..ca7367525 100644
--- a/frontend/src/pages/inbounds/form/transport/raw.tsx
+++ b/frontend/src/pages/inbounds/form/transport/raw.tsx
@@ -1,157 +1,115 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
export default function RawForm() {
const { t } = useTranslation();
+ const { control, setValue } = useFormContext();
+ const headerType = (useWatch({
+ control,
+ name: 'streamSettings.tcpSettings.header.type',
+ }) ?? 'none') as string;
return (
<>
-
-
+
-
- prev.streamSettings?.tcpSettings?.header?.type
- !== curr.streamSettings?.tcpSettings?.header?.type
- }
- >
- {({ getFieldValue, setFieldValue }) => {
- const headerType = getFieldValue(
- ['streamSettings', 'tcpSettings', 'header', 'type'],
- ) as string | undefined;
- return (
- {
- setFieldValue(
- ['streamSettings', 'tcpSettings', 'header'],
- v
- ? {
- type: 'http',
- request: {
- version: '1.1',
- method: 'GET',
- path: ['/'],
- headers: {},
- },
- response: {
- version: '1.1',
- status: '200',
- reason: 'OK',
- headers: {},
- },
- }
- : { type: 'none' },
- );
- }}
- />
+ {
+ setValue(
+ 'streamSettings.tcpSettings.header',
+ v
+ ? {
+ type: 'http',
+ request: {
+ version: '1.1',
+ method: 'GET',
+ path: ['/'],
+ headers: {},
+ },
+ response: {
+ version: '1.1',
+ status: '200',
+ reason: 'OK',
+ headers: {},
+ },
+ }
+ : { type: 'none' },
);
}}
-
-
-
- prev.streamSettings?.tcpSettings?.header?.type
- !== curr.streamSettings?.tcpSettings?.header?.type
- }
- >
- {({ getFieldValue }) => {
- const headerType = getFieldValue(
- ['streamSettings', 'tcpSettings', 'header', 'type'],
- ) as string | undefined;
- if (headerType !== 'http') return null;
- return (
- <>
-
-
-
-
-
-
- ({ value: Array.isArray(v) ? v.join(',') : v })}
- getValueFromEvent={(e) => {
- const raw = (e?.target?.value ?? '') as string;
- const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
- return parts.length > 0 ? parts : ['/'];
- }}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
- }}
+ />
+ {headerType === 'http' && (
+ <>
+
+
+
+
+
+
+ (Array.isArray(v) ? v.join(',') : v),
+ output: (raw) => {
+ const parts = String(raw ?? '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+ return parts.length > 0 ? parts : ['/'];
+ },
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
>
);
}
diff --git a/frontend/src/pages/inbounds/form/transport/sockopt.tsx b/frontend/src/pages/inbounds/form/transport/sockopt.tsx
index 78a803c0d..879a91802 100644
--- a/frontend/src/pages/inbounds/form/transport/sockopt.tsx
+++ b/frontend/src/pages/inbounds/form/transport/sockopt.tsx
@@ -1,19 +1,21 @@
import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
-import { CustomSockoptList } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
+import { SockoptCustomField } from '@/lib/xray/forms/fields';
import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
-// Transport key that carries its own acceptProxyProtocol field (mirrored
-// alongside the sockopt-level one so the PROXY preset never silently no-ops).
+/* Transport key that carries its own acceptProxyProtocol field (mirrored
+ alongside the sockopt-level one so the PROXY preset never silently no-ops). */
const TRANSPORT_PROXY_FIELD: Record = {
tcp: 'tcpSettings',
ws: 'wsSettings',
httpupgrade: 'httpupgradeSettings',
};
-// Transports on which xray-core honors sockopt.trustedXForwardedFor. gRPC joined
-// in v26.6.22 (xray-core 711aea4): it now reads X-Forwarded-For via this option
-// instead of the old x-real-ip gRPC metadata.
+/* Transports on which xray-core honors sockopt.trustedXForwardedFor. gRPC joined
+ in v26.6.22 (xray-core 711aea4): it now reads X-Forwarded-For via this option
+ instead of the old x-real-ip gRPC metadata. */
const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp', 'grpc'];
type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy';
@@ -26,252 +28,207 @@ export default function SockoptForm({
network: string;
}) {
const { t } = useTranslation();
+ const { control, getValues, setValue } = useFormContext();
+ const sock = useWatch({ control, name: 'streamSettings.sockopt' });
+ const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
- // Presets write the same sockopt fields the user could set by hand below,
- // picking the mechanism xray-core actually honors for the chosen transport:
- // CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp/grpc) or the
- // PROXY-protocol header via acceptProxyProtocol (every transport but mKCP).
- const applyRealClientIpPreset = (
- preset: RealClientIpPreset,
- getFieldValue: (name: (string | number)[]) => unknown,
- setFieldValue: (name: (string | number)[], value: unknown) => void,
- ) => {
- const sockopt = getFieldValue(['streamSettings', 'sockopt']);
+ const transportField = TRANSPORT_PROXY_FIELD[network];
+ const sockAcceptPP = useWatch({ control, name: 'streamSettings.sockopt.acceptProxyProtocol' });
+ const sockTrusted = useWatch({ control, name: 'streamSettings.sockopt.trustedXForwardedFor' });
+ const transportAcceptPP = useWatch({
+ control,
+ name: transportField ? `streamSettings.${transportField}.acceptProxyProtocol` : 'streamSettings.__noTransportProxyField',
+ });
+
+ /* Presets write the same sockopt fields the user could set by hand below,
+ picking the mechanism xray-core actually honors for the chosen transport:
+ CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp/grpc) or the
+ PROXY-protocol header via acceptProxyProtocol (every transport but mKCP). */
+ const applyRealClientIpPreset = (preset: RealClientIpPreset) => {
+ const sockopt = getValues('streamSettings.sockopt');
const sockoptOn =
!!sockopt && typeof sockopt === 'object' && Object.keys(sockopt as object).length > 0;
if (preset !== 'off' && !sockoptOn) {
toggleSockopt(true);
}
- const transportField = TRANSPORT_PROXY_FIELD[network];
if (preset === 'off') {
- setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
- setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
- if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
+ setValue('streamSettings.sockopt.trustedXForwardedFor', []);
+ setValue('streamSettings.sockopt.acceptProxyProtocol', false);
+ if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, false);
return;
}
if (preset === 'cloudflare') {
- const current = getFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor']);
+ const current = getValues('streamSettings.sockopt.trustedXForwardedFor');
const list = Array.isArray(current) ? [...(current as string[])] : [];
if (!list.includes('CF-Connecting-IP')) list.push('CF-Connecting-IP');
- setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], list);
- setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
- if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
+ setValue('streamSettings.sockopt.trustedXForwardedFor', list);
+ setValue('streamSettings.sockopt.acceptProxyProtocol', false);
+ if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, false);
return;
}
- // proxy — clear trustedXForwardedFor so a lingering header can't override the
- // PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp/grpc).
- setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
- setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], true);
- if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], true);
+ /* proxy — clear trustedXForwardedFor so a lingering header can't override the
+ PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp/grpc). */
+ setValue('streamSettings.sockopt.trustedXForwardedFor', []);
+ setValue('streamSettings.sockopt.acceptProxyProtocol', true);
+ if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, true);
};
+ const transportPP = transportField ? transportAcceptPP === true : false;
+ const proxyOn = sockAcceptPP === true || transportPP;
+ const trusted = Array.isArray(sockTrusted) ? (sockTrusted as string[]) : [];
+ const presetValue: RealClientIpPreset = proxyOn
+ ? 'proxy'
+ : trusted.length > 0
+ ? 'cloudflare'
+ : 'off';
+ const trustedMismatch = trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network);
+ const proxyMismatch = proxyOn && network === 'kcp';
+
return (
- {
- const a = (prev.streamSettings as { sockopt?: object } | undefined)?.sockopt;
- const b = (curr.streamSettings as { sockopt?: object } | undefined)?.sockopt;
- return !!a !== !!b;
- }}
- >
- {({ getFieldValue }) => {
- const sock = getFieldValue(['streamSettings', 'sockopt']);
- const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
- return (
- <>
-
-
-
- {on && (
- <>
- {
- type ProxyWatch = {
- streamSettings?: {
- sockopt?: { trustedXForwardedFor?: unknown; acceptProxyProtocol?: unknown };
- tcpSettings?: { acceptProxyProtocol?: unknown };
- wsSettings?: { acceptProxyProtocol?: unknown };
- httpupgradeSettings?: { acceptProxyProtocol?: unknown };
- };
- };
- const pick = (v: ProxyWatch) => {
- const s = v.streamSettings;
- return JSON.stringify([
- s?.sockopt?.trustedXForwardedFor,
- s?.sockopt?.acceptProxyProtocol,
- s?.tcpSettings?.acceptProxyProtocol,
- s?.wsSettings?.acceptProxyProtocol,
- s?.httpupgradeSettings?.acceptProxyProtocol,
- ]);
- };
- return pick(prev as ProxyWatch) !== pick(curr as ProxyWatch);
- }}
- >
- {({ getFieldValue, setFieldValue }) => {
- const sockopt = (getFieldValue(['streamSettings', 'sockopt']) ?? {}) as Record<
- string,
- unknown
- >;
- const transportField = TRANSPORT_PROXY_FIELD[network];
- const transportPP = transportField
- ? getFieldValue(['streamSettings', transportField, 'acceptProxyProtocol']) === true
- : false;
- const proxyOn = sockopt.acceptProxyProtocol === true || transportPP;
- const trusted = Array.isArray(sockopt.trustedXForwardedFor)
- ? (sockopt.trustedXForwardedFor as string[])
- : [];
- const value: RealClientIpPreset = proxyOn
- ? 'proxy'
- : trusted.length > 0
- ? 'cloudflare'
- : 'off';
- const trustedMismatch =
- trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network);
- const proxyMismatch = proxyOn && network === 'kcp';
- return (
- <>
-
-
- applyRealClientIpPreset(v as RealClientIpPreset, getFieldValue, setFieldValue)
- }
- options={[
- { value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
- { value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
- { value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
- ]}
- />
-
- {trustedMismatch && (
-
- )}
- {proxyMismatch && (
-
- )}
- >
- );
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ({ value: c, label: c }))}
- />
-
-
-
-
-
-
-
-
- >
+ <>
+
+
+
+ {on && (
+ <>
+
+ applyRealClientIpPreset(v as RealClientIpPreset)}
+ options={[
+ { value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
+ { value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
+ { value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
+ ]}
+ />
+
+ {trustedMismatch && (
+
+ )}
+ {proxyMismatch && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ({ value: c, label: c }))}
+ />
+
+
+
+
+
+
+
+ (
+
)}
- >
- );
- }}
-
+ />
+ >
+ )}
+ >
);
}
diff --git a/frontend/src/pages/inbounds/form/transport/ws.tsx b/frontend/src/pages/inbounds/form/transport/ws.tsx
index 86b8a3afb..9fbd2b22c 100644
--- a/frontend/src/pages/inbounds/form/transport/ws.tsx
+++ b/frontend/src/pages/inbounds/form/transport/ws.tsx
@@ -1,37 +1,38 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Switch } from 'antd';
+import { Input, InputNumber, Switch } from 'antd';
import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
export default function WsForm() {
const { t } = useTranslation();
return (
<>
-
-
-
+
+
-
-
+
+
-
-
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/inbounds/form/transport/xhttp.tsx b/frontend/src/pages/inbounds/form/transport/xhttp.tsx
index d597ff8bf..297158308 100644
--- a/frontend/src/pages/inbounds/form/transport/xhttp.tsx
+++ b/frontend/src/pages/inbounds/form/transport/xhttp.tsx
@@ -1,39 +1,53 @@
import { useTranslation } from 'react-i18next';
-import { AutoComplete, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
-import type { InboundFormValues } from '@/schemas/forms/inbound-form';
+import { FormField } from '@/components/form/rhf';
import { XHTTP_SESSION_ID_TABLES, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
-export default function XhttpForm({ form }: { form: FormInstance }) {
+function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise) {
+ return async (value: unknown): Promise => {
+ try {
+ await fn(undefined, value);
+ return true;
+ } catch (e) {
+ return (e as Error).message;
+ }
+ };
+}
+
+export default function XhttpForm() {
const { t } = useTranslation();
- const xhttpMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'mode'], form);
- const xhttpObfsMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'xPaddingObfsMode'], form) ?? false;
- const xhttpSessionIDPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionIDPlacement'], form);
- const xhttpSessionIDTable = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionIDTable'], form);
- const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
- const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
+ const { control, getValues, setValue } = useFormContext();
+ const xhttpMode = useWatch({ control, name: 'streamSettings.xhttpSettings.mode' }) as string | undefined;
+ const xhttpObfsMode = !!useWatch({ control, name: 'streamSettings.xhttpSettings.xPaddingObfsMode' });
+ const xhttpSessionIDPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDPlacement' }) as string | undefined;
+ const xhttpSessionIDTable = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDTable' });
+ const xhttpSeqPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.seqPlacement' }) as string | undefined;
+ const xhttpUplinkPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.uplinkDataPlacement' }) as string | undefined;
+ const enableXmux = !!useWatch({ control, name: 'streamSettings.xhttpSettings.enableXmux' });
function onXmuxToggle(checked: boolean) {
if (!checked) return;
- const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
+ const existing = getValues('streamSettings.xhttpSettings.xmux');
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
if (hasValues) return;
- form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
+ setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
}
return (
<>
-
+
-
-
+
+
-
-
+
+
({
@@ -41,64 +55,64 @@ export default function XhttpForm({ form }: { form: FormInstance
-
+
{(xhttpMode === 'packet-up' || xhttpMode === 'auto') && (
<>
-
-
-
+
-
-
+
-
+
>
)}
{xhttpMode === 'stream-up' && (
<>
-
-
-
+
-
+
>
)}
-
-
-
+
-
-
+
-
-
+
@@ -114,29 +128,29 @@ export default function XhttpForm({ form }: { form: FormInstance
-
-
+
-
+
{xhttpObfsMode && (
<>
-
-
-
+
-
-
+
@@ -149,8 +163,8 @@ export default function XhttpForm({ form }: { form: FormInstance
-
-
+
@@ -161,10 +175,10 @@ export default function XhttpForm({ form }: { form: FormInstance
-
+
>
)}
-
@@ -177,38 +191,38 @@ export default function XhttpForm({ form }: { form: FormInstance
-
+
{xhttpSessionIDPlacement && xhttpSessionIDPlacement !== 'path' && (
-
-
+
)}
-
({ value: v }))}
placeholder="Base62"
/>
-
- {xhttpSessionIDTable && (
-
+ {!!xhttpSessionIDTable && (
+
-
+
)}
-
@@ -221,18 +235,18 @@ export default function XhttpForm({ form }: { form: FormInstance
-
+
{xhttpSeqPlacement && xhttpSeqPlacement !== 'path' && (
-
-
+
)}
{xhttpMode === 'packet-up' && (
<>
-
@@ -245,83 +259,77 @@ export default function XhttpForm({ form }: { form: FormInstance
-
+
{xhttpUplinkPlacement && xhttpUplinkPlacement !== 'body' && (
-
-
+
)}
>
)}
-
-
+
{/* XMUX is the connection-multiplexing layer
xHTTP uses to fan out parallel requests over
a small pool of upstream connections. UI-only
toggle (enableXmux) hides the 6 nested knobs
when off. */}
- onXmuxToggle(v as boolean)}
>
-
-
-
- {() => {
- if (!form.getFieldValue([
- 'streamSettings', 'xhttpSettings', 'enableXmux',
- ])) return null;
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
- }}
-
+
+
+ {enableXmux && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
>
);
}
diff --git a/frontend/src/pages/inbounds/form/useSecurityActions.ts b/frontend/src/pages/inbounds/form/useSecurityActions.ts
index 8a47b3271..a5dc5e387 100644
--- a/frontend/src/pages/inbounds/form/useSecurityActions.ts
+++ b/frontend/src/pages/inbounds/form/useSecurityActions.ts
@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
-import type { FormInstance } from 'antd';
+import type { UseFormReturn } from 'react-hook-form';
import type { MessageInstance } from 'antd/es/message/interface';
import { HttpUtil, RandomUtil } from '@/utils';
@@ -10,23 +10,29 @@ import type { InboundFormValues } from '@/schemas/forms/inbound-form';
import type { RealityScanResult } from '@/generated/types';
interface UseSecurityActionsArgs {
- form: FormInstance;
+ methods: UseFormReturn;
setSaving: Dispatch>;
messageApi: MessageInstance;
- // Node the inbound is deployed to (null = central panel). "Set Cert from
- // Panel" must read the node's own cert paths for a node-assigned inbound —
- // the central panel's paths don't exist on the node. See issue #4854.
+ /*
+ * Node the inbound is deployed to (null = central panel). "Set Cert from
+ * Panel" must read the node's own cert paths for a node-assigned inbound —
+ * the central panel's paths don't exist on the node. See issue #4854.
+ */
nodeId: number | null;
setScanResult: Dispatch>;
setScanning: Dispatch>;
}
-// Server-side TLS / Reality key + certificate generation handlers for the
-// inbound modal's security tab. Each talks to a /panel server endpoint and
-// writes the result back into the form. Lifted out of InboundFormModal so
-// the modal body stays focused on orchestration.
-export function useSecurityActions({ form, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
+/*
+ * Server-side TLS / Reality key + certificate generation handlers for the
+ * inbound modal's security tab. Each talks to a /panel server endpoint and
+ * writes the result back into the form. Lifted out of InboundFormModal so
+ * the modal body stays focused on orchestration.
+ */
+export function useSecurityActions({ methods, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
const { t } = useTranslation();
+ const setValue = methods.setValue as unknown as (name: string, value: unknown) => void;
+ const getValues = methods.getValues as unknown as (name?: string) => unknown;
const genRealityKeypair = async () => {
setSaving(true);
@@ -34,8 +40,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
if (msg?.success) {
const obj = msg.obj as { privateKey: string; publicKey: string };
- form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
- form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
+ setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
+ setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
}
} finally {
setSaving(false);
@@ -43,8 +49,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
};
const clearRealityKeypair = () => {
- form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], '');
- form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], '');
+ setValue('streamSettings.realitySettings.privateKey', '');
+ setValue('streamSettings.realitySettings.settings.publicKey', '');
};
const genMldsa65 = async () => {
@@ -53,8 +59,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
if (msg?.success) {
const obj = msg.obj as { seed: string; verify: string };
- form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], obj.seed);
- form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], obj.verify);
+ setValue('streamSettings.realitySettings.mldsa65Seed', obj.seed);
+ setValue('streamSettings.realitySettings.settings.mldsa65Verify', obj.verify);
}
} finally {
setSaving(false);
@@ -62,20 +68,20 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
};
const clearMldsa65 = () => {
- form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], '');
- form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], '');
+ setValue('streamSettings.realitySettings.mldsa65Seed', '');
+ setValue('streamSettings.realitySettings.settings.mldsa65Verify', '');
};
const applyRealityScanResult = (r: RealityScanResult) => {
setScanResult(r);
- form.setFieldValue(['streamSettings', 'realitySettings', 'target'], r.target);
+ setValue('streamSettings.realitySettings.target', r.target);
if (r.serverNames?.length) {
- form.setFieldValue(['streamSettings', 'realitySettings', 'serverNames'], r.serverNames);
+ setValue('streamSettings.realitySettings.serverNames', r.serverNames);
}
};
const scanRealityTarget = async () => {
- const target = ((form.getFieldValue(['streamSettings', 'realitySettings', 'target']) as string | undefined) ?? '').trim();
+ const target = ((getValues('streamSettings.realitySettings.target') as string | undefined) ?? '').trim();
if (!target) {
messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
return;
@@ -118,28 +124,28 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
};
const randomizeShortIds = () => {
- form.setFieldValue(
- ['streamSettings', 'realitySettings', 'shortIds'],
+ setValue(
+ 'streamSettings.realitySettings.shortIds',
RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean),
);
};
const randomizeSpiderX = () => {
- form.setFieldValue(
- ['streamSettings', 'realitySettings', 'settings', 'spiderX'],
+ setValue(
+ 'streamSettings.realitySettings.settings.spiderX',
`/${RandomUtil.randomSeq(15)}`,
);
};
const getNewEchCert = async () => {
- const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']);
+ const sni = getValues('streamSettings.tlsSettings.serverName');
setSaving(true);
try {
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni });
if (msg?.success) {
const obj = msg.obj as { echServerKeys: string; echConfigList: string };
- form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], obj.echServerKeys);
- form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], obj.echConfigList);
+ setValue('streamSettings.tlsSettings.echServerKeys', obj.echServerKeys);
+ setValue('streamSettings.tlsSettings.settings.echConfigList', obj.echConfigList);
}
} finally {
setSaving(false);
@@ -147,15 +153,17 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
};
const clearEchCert = () => {
- form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], '');
- form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], '');
+ setValue('streamSettings.tlsSettings.echServerKeys', '');
+ setValue('streamSettings.tlsSettings.settings.echConfigList', '');
};
- // Fill the pinned-cert field from the inbound's own certificate: read the
- // first configured cert (file path or inline content) and ask the server for
- // its hex SHA-256, then merge the hash(es) into pinnedPeerCertSha256.
+ /*
+ * Fill the pinned-cert field from the inbound's own certificate: read the
+ * first configured cert (file path or inline content) and ask the server for
+ * its hex SHA-256, then merge the hash(es) into pinnedPeerCertSha256.
+ */
const pinFromCert = async () => {
- const certs = (form.getFieldValue(['streamSettings', 'tlsSettings', 'certificates']) ?? []) as Array<{
+ const certs = (getValues('streamSettings.tlsSettings.certificates') ?? []) as Array<{
certificateFile?: string;
certificate?: string[];
}>;
@@ -175,29 +183,33 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
}
const hashes = (msg.obj as string[] | undefined) ?? [];
if (hashes.length === 0) return;
- const current = (form.getFieldValue(
- ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
+ const current = (getValues(
+ 'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
) as string[] | undefined) ?? [];
const merged = Array.from(new Set([...current, ...hashes]));
- form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged);
+ setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
} finally {
setSaving(false);
}
};
- // Fill the pinned-cert field by pinging the configured SNI: fetches the live
- // remote certificate hash via `xray tls ping`. Useful when the panel doesn't
- // hold the cert file (a CDN front / external endpoint).
+ /*
+ * Fill the pinned-cert field by pinging the configured SNI: fetches the live
+ * remote certificate hash via `xray tls ping`. Useful when the panel doesn't
+ * hold the cert file (a CDN front / external endpoint).
+ */
const pinFromRemote = async () => {
- const server = ((form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']) as string | undefined) ?? '').trim();
+ const server = ((getValues('streamSettings.tlsSettings.serverName') as string | undefined) ?? '').trim();
if (!server) {
messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni'));
return;
}
- // `xray tls ping` defaults to :443, but a self-hosted inbound rarely
- // listens there. Append the inbound's own port (unless the SNI already
- // carries one) so the ping reaches the actual TLS endpoint.
- const port = form.getFieldValue('port') as number | undefined;
+ /*
+ * `xray tls ping` defaults to :443, but a self-hosted inbound rarely
+ * listens there. Append the inbound's own port (unless the SNI already
+ * carries one) so the ping reaches the actual TLS endpoint.
+ */
+ const port = getValues('port') as number | undefined;
const target = /:\d+$/.test(server) || !port ? server : `${server}:${port}`;
setSaving(true);
try {
@@ -208,11 +220,11 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
}
const hashes = (msg.obj as string[] | undefined) ?? [];
if (hashes.length === 0) return;
- const current = (form.getFieldValue(
- ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
+ const current = (getValues(
+ 'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
) as string[] | undefined) ?? [];
const merged = Array.from(new Set([...current, ...hashes]));
- form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged);
+ setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
} finally {
setSaving(false);
}
@@ -221,8 +233,10 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
const setCertFromPanel = async (certName: number) => {
setSaving(true);
try {
- // Node-assigned inbounds run on the node, so their cert files must be the
- // node's own paths (fetched through the central panel), not this panel's.
+ /*
+ * Node-assigned inbounds run on the node, so their cert files must be the
+ * node's own paths (fetched through the central panel), not this panel's.
+ */
const msg = typeof nodeId === 'number'
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
@@ -235,12 +249,12 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
return;
}
- form.setFieldValue(
- ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
+ setValue(
+ `streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
obj.webCertFile ?? '',
);
- form.setFieldValue(
- ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
+ setValue(
+ `streamSettings.tlsSettings.certificates.${certName}.keyFile`,
obj.webKeyFile ?? '',
);
} finally {
@@ -249,19 +263,19 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
};
const clearCertFiles = (certName: number) => {
- form.setFieldValue(
- ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
+ setValue(
+ `streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
'',
);
- form.setFieldValue(
- ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
+ setValue(
+ `streamSettings.tlsSettings.certificates.${certName}.keyFile`,
'',
);
};
const onSecurityChange = async (next: string) => {
setScanResult(null);
- const current = (form.getFieldValue('streamSettings') as Record) ?? {};
+ const current = (getValues('streamSettings') as Record) ?? {};
const cleaned: Record = { ...current, security: next };
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
@@ -275,18 +289,18 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
cleaned.realitySettings = reality;
}
- form.setFieldValue('streamSettings', cleaned);
+ setValue('streamSettings', cleaned);
if (next === 'reality') {
randomizeSpiderX();
try {
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
if (msg?.success) {
const obj = msg.obj as { privateKey: string; publicKey: string };
- form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
- form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
+ setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
+ setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
}
} catch {
- // best-effort: leave keypair fields empty if server call fails
+ /* best-effort: leave keypair fields empty if server call fails */
}
}
};
diff --git a/frontend/src/pages/login/LoginPage.tsx b/frontend/src/pages/login/LoginPage.tsx
index 55c358fa4..a74598bd4 100644
--- a/frontend/src/pages/login/LoginPage.tsx
+++ b/frontend/src/pages/login/LoginPage.tsx
@@ -22,8 +22,9 @@ import {
UserOutlined,
} from '@ant-design/icons';
+import { FormProvider, useForm } from 'react-hook-form';
import { HttpUtil, LanguageManager } from '@/utils';
-import { antdRule } from '@/utils/zodForm';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
@@ -48,6 +49,7 @@ export default function LoginPage() {
const [submitting, setSubmitting] = useState(false);
const [twoFactorEnable, setTwoFactorEnable] = useState(false);
const [headlineIndex, setHeadlineIndex] = useState(0);
+ const methods = useForm({ defaultValues: { username: '', password: '', twoFactorCode: '' } });
const [lang, setLang] = useState(() => LanguageManager.getLanguage());
const headlineWords = useMemo(
@@ -180,66 +182,67 @@ export default function LoginPage() {
{headlineWords[headlineIndex]}
-
-
+
- }
- autoComplete="username"
- size="large"
- placeholder={t('username')}
- autoFocus
- />
-
-
-
- }
- autoComplete="current-password"
- size="large"
- placeholder={t('password')}
- />
-
-
- {twoFactorEnable && (
-
}
- autoComplete="one-time-code"
+ prefix={ }
+ autoComplete="username"
size="large"
- placeholder={t('twoFactorCode')}
+ placeholder={t('username')}
+ autoFocus
/>
-
- )}
+
-
-
- {t('login')}
-
-
-
+ }
+ autoComplete="current-password"
+ size="large"
+ placeholder={t('password')}
+ />
+
+
+ {twoFactorEnable && (
+
+ }
+ autoComplete="one-time-code"
+ size="large"
+ placeholder={t('twoFactorCode')}
+ />
+
+ )}
+
+
+
+ {t('login')}
+
+
+
+
)}
diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx
index 36786e9b1..5538f1d8c 100644
--- a/frontend/src/pages/nodes/NodeFormModal.tsx
+++ b/frontend/src/pages/nodes/NodeFormModal.tsx
@@ -13,11 +13,12 @@ import {
Switch,
message,
} from 'antd';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
import type { Msg } from '@/utils';
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
-import { antdRule } from '@/utils/zodForm';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import './NodeFormModal.css';
@@ -65,7 +66,7 @@ export default function NodeFormModal({
onOpenChange,
}: NodeFormModalProps) {
const { t } = useTranslation();
- const [form] = Form.useForm();
+ const methods = useForm({ defaultValues: defaultValues() });
const [messageApi, messageContextHolder] = message.useMessage();
const [submitting, setSubmitting] = useState(false);
@@ -74,9 +75,9 @@ export default function NodeFormModal({
const [fetchingInbounds, setFetchingInbounds] = useState(false);
const [inboundOptions, setInboundOptions] = useState([]);
const [testResult, setTestResult] = useState(null);
- const scheme = Form.useWatch('scheme', form) ?? 'https';
- const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
- const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
+ const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
+ const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
+ const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector);
@@ -109,11 +110,10 @@ export default function NodeFormModal({
}
: base;
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
- form.resetFields();
- form.setFieldsValue(next);
+ methods.reset(next);
setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
setTestResult(null);
- }, [open, mode, node, form]);
+ }, [open, mode, node, methods]);
const title = useMemo(
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
@@ -141,15 +141,11 @@ export default function NodeFormModal({
}
async function onTest() {
- try {
- await form.validateFields(['address', 'port']);
- } catch {
- return;
- }
+ if (!(await methods.trigger(['address', 'port']))) return;
setTesting(true);
setTestResult(null);
try {
- const payload = buildPayload(form.getFieldsValue(true));
+ const payload = buildPayload(methods.getValues());
const msg = await testConnection(payload);
if (msg?.success && msg.obj) {
setTestResult(msg.obj);
@@ -162,17 +158,13 @@ export default function NodeFormModal({
}
async function onFetchPin() {
- try {
- await form.validateFields(['address', 'port']);
- } catch {
- return;
- }
+ if (!(await methods.trigger(['address', 'port']))) return;
setFetchingPin(true);
try {
- const payload = buildPayload(form.getFieldsValue(true));
+ const payload = buildPayload(methods.getValues());
const msg = await fetchFingerprint(payload);
if (msg?.success && msg.obj) {
- form.setFieldValue('pinnedCertSha256', msg.obj);
+ methods.setValue('pinnedCertSha256', msg.obj);
messageApi.success(t('pages.nodes.pinFetched'));
} else {
messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
@@ -183,14 +175,10 @@ export default function NodeFormModal({
}
async function onFetchInbounds() {
- try {
- await form.validateFields(['name', 'address', 'port', 'apiToken']);
- } catch {
- return;
- }
+ if (!(await methods.trigger(['name', 'address', 'port', 'apiToken']))) return;
setFetchingInbounds(true);
try {
- const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true)));
+ const msg = await fetchInbounds(buildPayload(methods.getValues()));
if (msg?.success && Array.isArray(msg.obj)) {
setInboundOptions(msg.obj);
messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
@@ -242,229 +230,229 @@ export default function NodeFormModal({
cancelText={t('cancel')}
mask={{ closable: false }}
width="640px"
- onOk={() => form.submit()}
+ onOk={methods.handleSubmit(onFinish)}
onCancel={close}
>
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- {
- if (value === 'http') form.setFieldValue('tlsVerifyMode', 'skip');
+
+
+ {
+ if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
}}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- {tlsVerifyMode === 'skip' && (
-
- )}
-
- {tlsVerifyMode === 'mtls' && (
-
- )}
-
- {tlsVerifyMode === 'pin' && (
-
-
-
- )}
+
+
-
-
-
-
- ({ value: (v as string) || undefined })}
- >
-
-
-
-
-
-
-
- {inboundSyncMode === 'selected' && (
-
(
- <>
-
- {t('pages.nodes.loadInbounds')}
-
- {menu}
- >
- )}
- options={inboundOptions.map((inbound) => ({
- value: inbound.tag,
- label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
- }))}
+ disabled={scheme === 'http'}
+ options={[
+ { value: 'verify', label: t('pages.nodes.tlsVerify') },
+ { value: 'pin', label: t('pages.nodes.tlsPin') },
+ { value: 'skip', label: t('pages.nodes.tlsSkip') },
+ { value: 'mtls', label: t('pages.nodes.tlsMtls') },
+ ]}
/>
-
- )}
+
-
-
- {t('pages.nodes.testConnection')}
-
- {testResult && (
-
- {testResult.status === 'online' ? (
-
- ) : (
-
- )}
-
+ {tlsVerifyMode === 'skip' && (
+
)}
-
-
+
+ {tlsVerifyMode === 'mtls' && (
+
+ )}
+
+ {tlsVerifyMode === 'pin' && (
+
+
+
+ )}
+
+
+
+
+
+ (v as string) || undefined }}
+ >
+
+
+
+
+
+
+
+ {inboundSyncMode === 'selected' && (
+
+ (
+ <>
+
+ {t('pages.nodes.loadInbounds')}
+
+ {menu}
+ >
+ )}
+ options={inboundOptions.map((inbound) => ({
+ value: inbound.tag,
+ label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
+ }))}
+ />
+
+ )}
+
+
+
+ {t('pages.nodes.testConnection')}
+
+ {testResult && (
+
+ {testResult.status === 'online' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
>
);
diff --git a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx
index 17aa08b54..68712bbf5 100644
--- a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx
+++ b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx
@@ -1,16 +1,18 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
+import type { Path } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
+import { FormField } from '@/components/form/rhf';
import {
BalancerFormSchema,
type BalancerFormValues,
} from '@/schemas/xray';
import {
BalancerStrategyTypeSchema,
- type BalancerStrategySettings,
type BalancerStrategyType,
} from '@/schemas/routing';
@@ -37,15 +39,7 @@ const STRATEGIES = BalancerStrategyTypeSchema.options.map((value) => ({
label: STRATEGY_LABELS[value] ?? value,
}));
-interface FormState {
- tag: string;
- strategy: BalancerStrategyType;
- selector: string[];
- fallbackTag: string;
- settings?: BalancerStrategySettings;
-}
-
-function initialState(balancer: BalancerFormValue | null): FormState {
+function initialState(balancer: BalancerFormValue | null): BalancerFormValues {
if (!balancer) {
return { tag: '', strategy: 'random', selector: [], fallbackTag: '' };
}
@@ -67,64 +61,46 @@ export default function BalancerFormModal({
onConfirm,
}: BalancerFormModalProps) {
const { t } = useTranslation();
- const [state, setState] = useState(() => initialState(balancer));
- const [touched, setTouched] = useState>>({});
+ const methods = useForm({ defaultValues: initialState(balancer) });
const [submitAttempted, setSubmitAttempted] = useState(false);
const isEdit = balancer != null;
- const update = (key: K, value: FormState[K]) => {
- setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
- setState((prev) => ({ ...prev, [key]: value }));
- };
-
- const parsed = useMemo(
- () => BalancerFormSchema.safeParse(state),
- [state],
- );
- const duplicateTag = !!state.tag.trim() && otherTags.includes(state.tag.trim());
- const issues = useMemo(() => {
- const map: Record = {};
- if (!parsed.success) {
- for (const issue of parsed.error.issues) {
- const key = String(issue.path[0] ?? '');
- if (!map[key]) map[key] = t(issue.message, { defaultValue: issue.message });
- }
+ useEffect(() => {
+ if (open) {
+ methods.reset(initialState(balancer));
+ setSubmitAttempted(false);
}
- return map;
- }, [parsed, t]);
+ }, [open, balancer, methods]);
- const showTagIssue = submitAttempted || !!touched.tag;
- const showSelectorIssue = submitAttempted || !!touched.selector;
- const tagError = showTagIssue ? issues.tag : '';
- const selectorError = showSelectorIssue ? issues.selector : '';
- const showDuplicate = showTagIssue && duplicateTag;
+ const strategy = useWatch({ control: methods.control, name: 'strategy' });
+ const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? [];
+ const costs = useWatch({ control: methods.control, name: 'settings.costs' }) ?? [];
function submit() {
+ const values = methods.getValues();
+ const parsed = BalancerFormSchema.safeParse(values);
+ const trimmedTag = (values.tag ?? '').trim();
+ const duplicateTag = !!trimmedTag && otherTags.includes(trimmedTag);
+ methods.clearErrors();
+ if (!parsed.success) {
+ const seen = new Set();
+ for (const issue of parsed.error.issues) {
+ const key = String(issue.path[0] ?? '');
+ if (key && !seen.has(key)) {
+ seen.add(key);
+ methods.setError(key as Path, { message: issue.message });
+ }
+ }
+ }
if (!parsed.success || duplicateTag) {
setSubmitAttempted(true);
return;
}
- const values = { ...parsed.data };
- if (values.strategy !== 'leastLoad') delete values.settings;
- onConfirm(values);
+ const result: BalancerFormValues = { ...parsed.data };
+ if (result.strategy !== 'leastLoad') delete result.settings;
+ onConfirm(result);
}
- const settings = state.settings;
- const updateSetting = (
- key: K,
- value: BalancerStrategySettings[K],
- ) => {
- setState((prev) => ({
- ...prev,
- settings: { ...(prev.settings ?? {}), [key]: value },
- }));
- };
- const updateBaselines = (next: string[]) => updateSetting('baselines', next);
- const updateCosts = (next: NonNullable) => updateSetting('costs', next);
-
- const baselines = settings?.baselines ?? [];
- const costs = settings?.costs ?? [];
-
const fallbackOptions = useMemo(
() => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })),
[outboundTags, t],
@@ -145,141 +121,144 @@ export default function BalancerFormModal({
onOk={submit}
onCancel={onClose}
>
-
-
- update('tag', e.target.value)}
- placeholder={t('pages.xray.balancer.tagPlaceholder')}
+
+
+ {
+ const trimmed = (field.value ?? '').trim();
+ const duplicate = !!trimmed && otherTags.includes(trimmed);
+ const errorMessage = fieldState.error?.message
+ ? t(fieldState.error.message, { defaultValue: fieldState.error.message })
+ : '';
+ const showDuplicate = !errorMessage && (submitAttempted || fieldState.isTouched) && duplicate;
+ return (
+
+ field.onChange(e.target.value)}
+ onBlur={field.onBlur}
+ ref={field.ref}
+ placeholder={t('pages.xray.balancer.tagPlaceholder')}
+ />
+
+ );
+ }}
/>
-
-
- update('strategy', v)}
- options={STRATEGIES}
- />
-
-
- update('selector', v)}
- tokenSeparators={[',']}
- options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
- />
-
-
- update('fallbackTag', v ?? '')}
- allowClear
- options={fallbackOptions}
- />
-
+
+
+
+
+ ({ value: tg, label: tg }))}
+ />
+
+ v ?? '' }}
+ >
+
+
- {state.strategy === 'leastLoad' && (
- <>
-
- updateSetting('expected', typeof v === 'number' ? v : undefined)}
- min={0}
- placeholder={t('pages.xray.balancer.expectedPlaceholder')}
- style={{ width: '100%' }}
- />
-
-
- updateSetting('maxRTT', e.target.value || undefined)}
- placeholder="e.g. 1s"
- />
-
-
- updateSetting('tolerance', typeof v === 'number' ? v : undefined)}
- min={0}
- max={1}
- step={0.01}
- placeholder="0.01 = 1%"
- style={{ width: '100%' }}
- />
-
-
- }
- aria-label={t('add')}
- onClick={() => updateBaselines([...baselines, ''])}
- />
- {baselines.map((b, idx) => (
-
- updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))}
- />
- updateBaselines(baselines.filter((_, i) => i !== idx))}>
-
-
-
- ))}
-
-
- }
- aria-label={t('add')}
- onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])}
- />
- {costs.map((c, idx) => (
-
- updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
- />
- updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
- />
- updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
- />
- updateCosts(costs.filter((_, i) => i !== idx))}>
-
-
-
- ))}
-
- >
- )}
-
+ {strategy === 'leastLoad' && (
+ <>
+ (typeof v === 'number' ? v : undefined) }}
+ >
+
+
+ v ?? '', output: (v) => (typeof v === 'string' && v ? v : undefined) }}
+ >
+
+
+ (typeof v === 'number' ? v : undefined) }}
+ >
+
+
+
+ }
+ aria-label={t('add')}
+ onClick={() => methods.setValue('settings.baselines', [...baselines, ''])}
+ />
+ {baselines.map((b, idx) => (
+
+ methods.setValue('settings.baselines', baselines.map((x, i) => (i === idx ? e.target.value : x)))}
+ />
+ methods.setValue('settings.baselines', baselines.filter((_, i) => i !== idx))}>
+
+
+
+ ))}
+
+
+ }
+ aria-label={t('add')}
+ onClick={() => methods.setValue('settings.costs', [...costs, { regexp: false, match: '', value: 1 }])}
+ />
+ {costs.map((c, idx) => (
+
+ methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
+ />
+ methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
+ />
+ methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
+ />
+ methods.setValue('settings.costs', costs.filter((_, i) => i !== idx))}>
+
+
+
+ ))}
+
+ >
+ )}
+
+
);
}
diff --git a/frontend/src/pages/xray/dns/DnsServerModal.tsx b/frontend/src/pages/xray/dns/DnsServerModal.tsx
index 152092cd0..fb20231e6 100644
--- a/frontend/src/pages/xray/dns/DnsServerModal.tsx
+++ b/frontend/src/pages/xray/dns/DnsServerModal.tsx
@@ -2,15 +2,16 @@ import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
import {
DnsQueryStrategySchema,
DnsServerObjectInnerSchema,
DnsServerObjectSchema,
type DnsServerObject,
} from '@/schemas/dns';
-import { antdRule } from '@/utils/zodForm';
export type DnsServerValue =
| string
@@ -135,17 +136,15 @@ export default function DnsServerModal({
onConfirm,
}: DnsServerModalProps) {
const { t } = useTranslation();
- const [form] = Form.useForm();
+ const methods = useForm({ defaultValues: defaultFormValues() });
+ const domains = useWatch({ control: methods.control, name: 'domains' }) ?? [];
+ const expectedIPs = useWatch({ control: methods.control, name: 'expectedIPs' }) ?? [];
+ const unexpectedIPs = useWatch({ control: methods.control, name: 'unexpectedIPs' }) ?? [];
useEffect(() => {
if (!open) return;
- form.setFieldsValue(valuesFromServer(server));
- }, [open, server, form]);
-
- async function submit() {
- const values = await form.validateFields();
- onConfirm(valuesToWire(values));
- }
+ methods.reset(valuesFromServer(server));
+ }, [open, server, methods]);
const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
@@ -156,124 +155,112 @@ export default function DnsServerModal({
okText={t('confirm')}
cancelText={t('close')}
mask={{ closable: false }}
- onOk={submit}
+ onOk={methods.handleSubmit((values) => onConfirm(valuesToWire(values)))}
onCancel={onClose}
>
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
- ({ value: s, label: s }))}
- />
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ({ value: s, label: s }))}
+ />
+
+
+
+
-
+
-
- {(fields, { add, remove }) => (
-
- } aria-label={t('add')} onClick={() => add('')} />
- {fields.map((field) => (
-
-
-
-
- remove(field.name)}>
-
-
-
- ))}
-
- )}
-
+
+ } aria-label={t('add')} onClick={() => methods.setValue('domains', [...domains, ''])} />
+ {domains.map((_, i) => (
+
+
+
+
+ methods.setValue('domains', domains.filter((__, idx) => idx !== i))}>
+
+
+
+ ))}
+
-
- {(fields, { add, remove }) => (
-
- } aria-label={t('add')} onClick={() => add('')} />
- {fields.map((field) => (
-
-
-
-
- remove(field.name)}>
-
-
-
- ))}
-
- )}
-
+
+ } aria-label={t('add')} onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])} />
+ {expectedIPs.map((_, i) => (
+
+
+
+
+ methods.setValue('expectedIPs', expectedIPs.filter((__, idx) => idx !== i))}>
+
+
+
+ ))}
+
-
- {(fields, { add, remove }) => (
-
- } aria-label={t('add')} onClick={() => add('')} />
- {fields.map((field) => (
-
-
-
-
- remove(field.name)}>
-
-
-
- ))}
-
- )}
-
+
+ } aria-label={t('add')} onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])} />
+ {unexpectedIPs.map((_, i) => (
+
+
+
+
+ methods.setValue('unexpectedIPs', unexpectedIPs.filter((__, idx) => idx !== i))}>
+
+
+
+ ))}
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
index f6aee0a8c..a4417fdf9 100644
--- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
+++ b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
@@ -11,8 +11,9 @@ import {
Tabs,
message,
} from 'antd';
-import { FinalMaskForm } from '@/lib/xray/forms/transport';
-import SniffingFields from '@/lib/xray/forms/SniffingFields';
+import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
+import { FinalMaskField, SniffingField } from '@/lib/xray/forms/fields';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { JsonEditor } from '@/components/form';
import { Wireguard } from '@/utils';
import {
@@ -31,7 +32,6 @@ import {
canEnableTls,
canEnableTlsFlow,
} from '@/lib/xray/protocol-capabilities';
-import { antdRule } from '@/utils/zodForm';
import {
FLOW_OPTIONS,
@@ -75,10 +75,7 @@ import {
import { RealityForm, TlsForm } from './security';
import './OutboundFormModal.css';
-// Pattern A rewrite of OutboundFormModal. Built as a sibling `.new.tsx`
-// file so the build stays green section-by-section. The atomic swap at
-// the end of the rewrite replaces the legacy file in one commit
-// (per Core Decision 7 in the migration spec).
+type StreamValue = OutboundFormValues['streamSettings'];
interface OutboundFormModalProps {
open: boolean;
@@ -89,7 +86,6 @@ interface OutboundFormModalProps {
onConfirm: (outbound: Record) => void;
}
-
export default function OutboundFormModal({
open,
outbound: outboundProp,
@@ -100,15 +96,36 @@ export default function OutboundFormModal({
}: OutboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
- const [form] = Form.useForm();
+ const methods = useForm({ defaultValues: buildAddModeValues() });
const [activeKey, setActiveKey] = useState('1');
const [jsonText, setJsonText] = useState('');
const [jsonDirty, setJsonDirty] = useState(false);
const [linkInput, setLinkInput] = useState('');
- // Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
- // hysteria2:// / wireguard://) and replace form state with the result.
- // The current tag is preserved when the parsed link doesn't carry one.
+ const isEdit = outboundProp != null;
+ const title = isEdit
+ ? `${t('edit')} ${t('pages.xray.Outbounds')}`
+ : `+ ${t('pages.xray.Outbounds')}`;
+ const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
+
+ const tag = (useWatch({ control: methods.control, name: 'tag' }) ?? '') as string;
+ const protocol = (useWatch({ control: methods.control, name: 'protocol' }) ?? 'vless') as string;
+ const network = (useWatch({ control: methods.control, name: 'streamSettings.network' }) ?? '') as string;
+ const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ?? 'none') as string;
+ const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string;
+ const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' });
+ const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as string | undefined;
+
+ const streamAllowed = canEnableStream({ protocol });
+ const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
+ const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
+ const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
+
+ /*
+ * Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
+ * hysteria2:// / wireguard://) and replace form state with the result.
+ * The current tag is preserved when the parsed link doesn't carry one.
+ */
function importLink() {
const link = linkInput.trim();
if (!link) return;
@@ -117,11 +134,10 @@ export default function OutboundFormModal({
messageApi.error('Wrong Link!');
return;
}
- const currentTag = form.getFieldValue('tag') as string | undefined;
+ const currentTag = methods.getValues('tag');
if (!parsed.tag && currentTag) parsed.tag = currentTag;
const next = rawOutboundToFormValues(parsed);
- form.resetFields();
- form.setFieldsValue(next);
+ methods.reset(next);
setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
setJsonDirty(false);
setLinkInput('');
@@ -129,89 +145,76 @@ export default function OutboundFormModal({
switchTab('1');
}
- const isEdit = outboundProp != null;
- const title = isEdit
- ? `${t('edit')} ${t('pages.xray.Outbounds')}`
- : `+ ${t('pages.xray.Outbounds')}`;
- const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
-
useEffect(() => {
if (!open) return;
const initial = outboundProp
? rawOutboundToFormValues(outboundProp)
: buildAddModeValues();
- form.resetFields();
- form.setFieldsValue(initial);
+ methods.reset(initial);
setActiveKey('1');
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
setJsonDirty(false);
- }, [open, outboundProp, form]);
-
- const tag = Form.useWatch('tag', form) ?? '';
- const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
- const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
- const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
- const streamAllowed = canEnableStream({ protocol });
- const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
- const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
- const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
+ }, [open, outboundProp, methods]);
useEffect(() => {
if (!streamAllowed) return;
- // Wireguard dials its own UDP — only finalmask/sockopt apply, never a
- // transport. Don't seed network 'tcp'; clear a leftover one (from a
- // protocol switch) so the transmission/security blocks stay hidden.
+ /*
+ * Wireguard dials its own UDP — only finalmask/sockopt apply, never a
+ * transport. Don't seed network 'tcp'; clear a leftover one (from a
+ * protocol switch) so the transmission/security blocks stay hidden.
+ */
if (protocol === 'wireguard') {
- if (network) form.setFieldValue('streamSettings', { security: 'none' });
+ if (network) methods.setValue('streamSettings', { security: 'none' } as StreamValue);
return;
}
if (network) return;
- form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [streamAllowed, network, protocol]);
+ methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue);
+ }, [streamAllowed, network, protocol, methods]);
useEffect(() => {
if (protocol !== 'hysteria') return;
if (network === 'hysteria' && security === 'tls') return;
- const existing = (form.getFieldValue('streamSettings') ?? {}) as Record;
+ const existing = (methods.getValues('streamSettings') ?? {}) as Record;
const slice = hysteriaStreamSlice();
if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
- form.setFieldValue('streamSettings', slice);
- }, [protocol, network, security, form]);
+ methods.setValue('streamSettings', slice as StreamValue);
+ }, [protocol, network, security, methods]);
- const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
useEffect(() => {
if (protocol !== 'wireguard') return;
const sk = (wgSecretKey ?? '').trim();
if (!sk) {
- form.setFieldValue(['settings', 'pubKey'], '');
+ methods.setValue('settings.pubKey', '');
return;
}
try {
const { publicKey } = Wireguard.generateKeypair(sk);
- form.setFieldValue(['settings', 'pubKey'], publicKey);
+ methods.setValue('settings.pubKey', publicKey);
} catch {
- form.setFieldValue(['settings', 'pubKey'], '');
+ methods.setValue('settings.pubKey', '');
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [protocol, wgSecretKey]);
+ }, [protocol, wgSecretKey, methods]);
- function onValuesChange(changed: Partial) {
- if ('protocol' in changed && changed.protocol) {
- const next = rawOutboundToFormValues({ protocol: changed.protocol });
- form.setFieldValue('settings', next.settings);
- if (changed.protocol === 'hysteria') {
- form.setFieldValue('streamSettings', hysteriaStreamSlice());
- } else if ((form.getFieldValue(['streamSettings', 'network']) ?? '') === 'hysteria') {
- form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
+ useEffect(() => {
+ /* eslint-disable-next-line react-hooks/incompatible-library */
+ const sub = methods.watch((_value, { name, type }) => {
+ if (name !== 'protocol' || type !== 'change') return;
+ const nextProtocol = methods.getValues('protocol');
+ const next = rawOutboundToFormValues({ protocol: nextProtocol });
+ methods.setValue('settings', next.settings);
+ if (nextProtocol === 'hysteria') {
+ methods.setValue('streamSettings', hysteriaStreamSlice() as StreamValue);
+ } else if ((methods.getValues('streamSettings.network') ?? '') === 'hysteria') {
+ methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue);
}
- }
- }
+ });
+ return () => sub.unsubscribe();
+ }, [methods]);
function onSecurityChange(next: string) {
- const stream = form.getFieldValue('streamSettings') ?? {};
- const cleaned = { ...stream } as Record;
+ const stream = (methods.getValues('streamSettings') ?? {}) as Record;
+ const cleaned = { ...stream };
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
if (next === 'tls') {
@@ -234,23 +237,25 @@ export default function OutboundFormModal({
};
}
cleaned.security = next;
- form.setFieldValue('streamSettings', cleaned);
+ methods.setValue('streamSettings', cleaned as StreamValue);
}
- // Network change cascade: swap the per-network sub-key (tcpSettings,
- // wsSettings, etc.) so the DU branch matches. Preserve security if
- // the new network supports it, otherwise force back to 'none'.
+ /*
+ * Network change cascade: swap the per-network sub-key (tcpSettings,
+ * wsSettings, etc.) so the DU branch matches. Preserve security if
+ * the new network supports it, otherwise force back to 'none'.
+ */
function onNetworkChange(next: string) {
- const stream = (form.getFieldValue('streamSettings') ?? {}) as Record;
- form.setFieldValue('streamSettings', applyNetworkChange(protocol, stream, next));
+ const stream = (methods.getValues('streamSettings') ?? {}) as Record;
+ methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue);
}
function onXmuxToggle(checked: boolean) {
if (!checked) return;
- const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
+ const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
if (hasValues) return;
- form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
+ methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
}
const duplicateTag = useMemo(() => {
@@ -260,9 +265,11 @@ export default function OutboundFormModal({
return (existingTags || []).includes(myTag);
}, [tag, existingTags, isEdit, outboundProp]);
- // Bridge form ↔ JSON tab: when leaving the JSON tab back to Basic, push
- // any edits into form state. When entering JSON tab, snapshot current
- // form values so the user sees the live shape.
+ /*
+ * Bridge form <-> JSON tab: when leaving the JSON tab back to Basic, push
+ * any edits into form state. When entering JSON tab, snapshot current
+ * form values so the user sees the live shape.
+ */
function applyJsonToForm(): boolean {
if (!jsonDirty) return true;
const raw = jsonText.trim();
@@ -275,8 +282,7 @@ export default function OutboundFormModal({
return false;
}
const next = rawOutboundToFormValues(parsed);
- form.resetFields();
- form.setFieldsValue(next);
+ methods.reset(next);
setJsonDirty(false);
return true;
}
@@ -290,7 +296,7 @@ export default function OutboundFormModal({
function onTabChange(key: string) {
if (key === '2') {
- const values = form.getFieldsValue(true) as OutboundFormValues;
+ const values = methods.getValues();
setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
setJsonDirty(false);
switchTab(key);
@@ -315,16 +321,11 @@ export default function OutboundFormModal({
return;
}
values = rawOutboundToFormValues(parsed);
- form.resetFields();
- form.setFieldsValue(values);
+ methods.reset(values);
setJsonDirty(false);
} else {
- try {
- await form.validateFields();
- } catch {
- return;
- }
- values = form.getFieldsValue(true) as OutboundFormValues;
+ if (!(await methods.trigger())) return;
+ values = methods.getValues();
}
const tagValue = (values.tag ?? '').trim();
if (!tagValue) {
@@ -354,218 +355,226 @@ export default function OutboundFormModal({
onCancel={onClose}
destroyOnHidden
>
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
- {SERVER_PROTOCOLS.has(protocol) && }
- {protocol === 'vmess' && }
- {protocol === 'vless' && }
- {protocol === 'trojan' && }
- {protocol === 'shadowsocks' && }
- {protocol === 'http' && }
- {protocol === 'socks' && }
-
- {protocol === 'loopback' && }
- {protocol === 'blackhole' && }
- {protocol === 'dns' && }
-
- {protocol === 'freedom' && }
-
- {protocol === 'vless' && (
-
- {() => {
- const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
- if (!reverseTag) return null;
+ {
+ const errorMessage = fieldState.error?.message
+ ? t(fieldState.error.message, { defaultValue: fieldState.error.message })
+ : '';
return (
-
+
+ field.onChange(e.target.value)}
+ onBlur={field.onBlur}
+ ref={field.ref}
+ placeholder={t('pages.xray.outboundForm.tagPlaceholder')}
+ />
+
);
}}
-
- )}
+ />
- {protocol === 'wireguard' && }
+
+
+
- {streamAllowed && network && (
- <>
-
-
-
+
+
+
- {network === 'tcp' && }
+ {SERVER_PROTOCOLS.has(protocol) && }
+ {protocol === 'vmess' && }
+ {protocol === 'vless' && }
+ {protocol === 'trojan' && }
+ {protocol === 'shadowsocks' && }
+ {protocol === 'http' && }
+ {protocol === 'socks' && }
- {network === 'kcp' && }
+ {protocol === 'loopback' && }
+ {protocol === 'blackhole' && }
+ {protocol === 'dns' && }
- {network === 'ws' && }
+ {protocol === 'freedom' && }
- {network === 'grpc' && }
-
- {network === 'httpupgrade' && }
-
- {network === 'xhttp' && }
-
- {network === 'hysteria' && }
- >
- )}
-
- {tlsFlowAllowed && (
-
- (
+
+ )}
/>
-
- )}
+ )}
- {/* Vision seed knobs only meaningful for the exact
- xtls-rprx-vision flow, on TCP+(tls|reality). The
- legacy class gated this on `canEnableVisionSeed()`
- — same condition encoded inline here. */}
-
- {() => {
- const flow =
- (form.getFieldValue(['settings', 'flow']) ?? '') as string;
- if (!(tlsFlowAllowed && flow === 'xtls-rprx-vision')) return null;
- return (
- <>
-
-
-
-
-
- {[900, 500, 900, 256].map((def, i) => (
-
-
-
- ))}
-
-
- >
- );
- }}
-
+ {protocol === 'wireguard' && }
- {streamAllowed && network && (
-
- onSecurityChange(e.target.value as string)}
- >
- {network !== 'hysteria' && {t('none')} }
- {tlsAllowed && TLS }
- {realityAllowed && Reality }
-
-
- )}
+ {streamAllowed && network && (
+ <>
+
+
+
- {security === 'tls' && tlsAllowed && }
+ {network === 'tcp' && }
- {security === 'reality' && realityAllowed && }
+ {network === 'kcp' && }
- {((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
-
- )}
+ {network === 'ws' && }
-
+ {network === 'grpc' && }
-
- >
- ),
- },
- {
- key: '2',
- label: 'JSON',
- children: (
-
- setLinkInput(e.target.value)}
- onSearch={importLink}
- />
- {
- setJsonText(next);
- setJsonDirty(true);
- }}
- minHeight="360px"
- maxHeight="600px"
- />
-
- ),
- },
- ]}
- />
-
+ {network === 'httpupgrade' && }
+
+ {network === 'xhttp' && }
+
+ {network === 'hysteria' && }
+ >
+ )}
+
+ {tlsFlowAllowed && (
+
+
+
+ )}
+
+ {/* Vision seed knobs only meaningful for the exact
+ xtls-rprx-vision flow, on TCP+(tls|reality). */}
+ {tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
+ <>
+
+
+
+
+
+ {[0, 1, 2, 3].map((i) => (
+
+
+
+ ))}
+
+
+ >
+ )}
+
+ {streamAllowed && network && (
+
+ onSecurityChange(e.target.value as string)}
+ >
+ {network !== 'hysteria' && {t('none')} }
+ {tlsAllowed && TLS }
+ {realityAllowed && Reality }
+
+
+ )}
+
+ {security === 'tls' && tlsAllowed && }
+
+ {security === 'reality' && realityAllowed && }
+
+ {((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
+
+ )}
+
+ (
+
+ )}
+ />
+
+
+ >
+ ),
+ },
+ {
+ key: '2',
+ label: 'JSON',
+ children: (
+
+ setLinkInput(e.target.value)}
+ onSearch={importLink}
+ />
+ {
+ setJsonText(next);
+ setJsonDirty(true);
+ }}
+ minHeight="360px"
+ maxHeight="600px"
+ />
+
+ ),
+ },
+ ]}
+ />
+
+
>
);
diff --git a/frontend/src/pages/xray/outbounds/protocols/blackhole.tsx b/frontend/src/pages/xray/outbounds/protocols/blackhole.tsx
index fb919af20..7368e1c7e 100644
--- a/frontend/src/pages/xray/outbounds/protocols/blackhole.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/blackhole.tsx
@@ -1,10 +1,12 @@
import { useTranslation } from 'react-i18next';
-import { Form, Select } from 'antd';
+import { Select } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
export default function BlackholeFields() {
const { t } = useTranslation();
return (
-
+
-
+
);
}
diff --git a/frontend/src/pages/xray/outbounds/protocols/dns.tsx b/frontend/src/pages/xray/outbounds/protocols/dns.tsx
index bba6c65c1..c613841bf 100644
--- a/frontend/src/pages/xray/outbounds/protocols/dns.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/dns.tsx
@@ -1,15 +1,19 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext } from 'react-hook-form';
+import { FormField } from '@/components/form/rhf';
import { activateOnKey } from '@/utils/a11y';
import { DNSRuleActions } from '@/schemas/primitives';
export default function DnsFields() {
const { t } = useTranslation();
+ const { control } = useFormContext();
+ const { fields, append, remove } = useFieldArray({ control, name: 'settings.rules' });
return (
<>
-
+
-
-
+
+
-
-
+
+
-
-
+
+
+
+
+ }
+ aria-label={t('add')}
+ onClick={() => append({ action: 'direct', qType: '', domain: '', rCode: 0 })}
+ />
-
- {(fields, { add, remove }) => (
- <>
-
- }
- aria-label={t('add')}
- onClick={() => add({ action: 'direct', qType: '', domain: '', rCode: 0 })}
+ {fields.map((field, index) => (
+
+
+
+
{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}
+
remove(index)}
+ onKeyDown={activateOnKey(() => remove(index))}
/>
-
- {fields.map((field, index) => (
-
-
-
- {t('pages.xray.outboundForm.ruleN', { n: index + 1 })}
- remove(field.name)}
- onKeyDown={activateOnKey(() => remove(field.name))}
- />
-
-
-
- ({ value: a, label: a }))}
- />
-
-
-
-
-
-
-
-
-
-
-
- ))}
- >
- )}
-
+
+
+
+ ({ value: a, label: a }))}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ ))}
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/protocols/freedom.tsx b/frontend/src/pages/xray/outbounds/protocols/freedom.tsx
index 296d816e4..f9fc1237a 100644
--- a/frontend/src/pages/xray/outbounds/protocols/freedom.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/freedom.tsx
@@ -1,30 +1,56 @@
import { useTranslation } from 'react-i18next';
-import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
+import { FormField } from '@/components/form/rhf';
import { activateOnKey } from '@/utils/a11y';
import { OutboundDomainStrategies } from '@/schemas/primitives';
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
-export default function FreedomFields({ form }: { form: FormInstance }) {
+interface FragmentValue {
+ packets?: string;
+ length?: string;
+ interval?: string;
+ maxSplit?: string;
+}
+
+export default function FreedomFields() {
const { t } = useTranslation();
+ const { control, setValue } = useFormContext();
+
+ const fragment = (useWatch({ control, name: 'settings.fragment' }) ?? {}) as FragmentValue;
+ const fragmentEnabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
+
+ const {
+ fields: noiseFields,
+ append: appendNoise,
+ remove: removeNoise,
+ } = useFieldArray({ control, name: 'settings.noises' });
+
+ const {
+ fields: finalRuleFields,
+ append: appendFinalRule,
+ remove: removeFinalRule,
+ } = useFieldArray({ control, name: 'settings.finalRules' });
+ const finalRulesValues = (useWatch({ control, name: 'settings.finalRules' }) ?? []) as { action?: string }[];
+
return (
<>
-
+
({ value: s, label: s })),
]}
/>
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
- {() => {
- const fragment = (form.getFieldValue(['settings', 'fragment']) ?? {}) as {
- packets?: string;
- length?: string;
- interval?: string;
- maxSplit?: string;
- };
- const enabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
- return (
- <>
-
- {
- form.setFieldValue(
- ['settings', 'fragment'],
- checked
- ? {
- packets: 'tlshello',
- length: '100-200',
- interval: '10-20',
- maxSplit: '300-400',
- }
- : { packets: '', length: '', interval: '', maxSplit: '' },
- );
- }}
- />
-
- {enabled && (
- <>
- {
- const str = String(value ?? '').trim();
- // xray accepts "tlshello" or any packet-number range (#5075)
- if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) {
- return Promise.resolve();
- }
- return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
- },
- }]}
- >
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
- >
- );
- }}
-
-
-
- {(fields, { add, remove }) => (
- <>
-
- 0}
- onChange={(checked) => {
- if (checked) {
- add({
- type: 'rand',
- packet: '10-20',
- delay: '10-16',
- applyTo: 'ip',
- });
- } else {
- // remove() with no arg is not supported;
- // walk fields in reverse and drop each.
- for (let i = fields.length - 1; i >= 0; i--) {
- remove(fields[i].name);
- }
- }
- }}
- />
- {fields.length > 0 && (
- }
- aria-label={t('add')}
- onClick={() =>
- add({
- type: 'rand',
- packet: '10-20',
- delay: '10-16',
- applyTo: 'ip',
- })
- }
- />
- )}
-
- {fields.map((field, index) => (
-
-
-
- {t('pages.settings.subFormats.noiseItem', { n: index + 1 })}
- {fields.length > 1 && (
- remove(field.name)}
- onKeyDown={activateOnKey(() => remove(field.name))}
- />
- )}
-
-
-
- ({
- value: v,
- label: v,
- }))}
- />
-
-
-
-
-
-
-
-
- ({
- value: v,
- label: v,
- }))}
- />
-
-
- ))}
- >
- )}
-
-
-
- {(fields, { add, remove }) => (
- <>
-
- }
- aria-label={t('add')}
- onClick={() =>
- add({
- action: 'allow',
- network: '',
- port: '',
- ip: [],
- blockDelay: '',
- })
+
+ {
+ setValue(
+ 'settings.fragment',
+ checked
+ ? {
+ packets: 'tlshello',
+ length: '100-200',
+ interval: '10-20',
+ maxSplit: '300-400',
}
- />
-
- {t('pages.xray.outboundForm.overrideXrayPrivateIp')}
-
-
- {fields.map((field, index) => (
-
-
-
- {t('pages.xray.outboundForm.ruleN', { n: index + 1 })}
- remove(field.name)}
- onKeyDown={activateOnKey(() => remove(field.name))}
- />
-
-
-
- ({
- value: v,
- label: v,
- }))}
- />
-
-
- ({
- value: v,
- label: v,
- }))}
- />
-
-
-
-
-
-
-
-
- {() => {
- const ruleAction = form.getFieldValue([
- 'settings',
- 'finalRules',
- field.name,
- 'action',
- ]);
- if (ruleAction !== 'block') return null;
- return (
-
-
-
- );
- }}
-
-
- ))}
- >
+ : { packets: '', length: '', interval: '', maxSplit: '' },
+ );
+ }}
+ />
+
+ {fragmentEnabled && (
+ <>
+ {
+ const str = String(value ?? '').trim();
+ /* xray accepts "tlshello" or any packet-number range (#5075) */
+ if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) return true;
+ return 'Use "tlshello" or a packet range like 1-3';
+ },
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ 0}
+ onChange={(checked) => {
+ if (checked) {
+ appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' });
+ } else {
+ removeNoise();
+ }
+ }}
+ />
+ {noiseFields.length > 0 && (
+ }
+ aria-label={t('add')}
+ onClick={() => appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })}
+ />
)}
-
+
+ {noiseFields.map((field, index) => (
+
+
+
+ {t('pages.settings.subFormats.noiseItem', { n: index + 1 })}
+ {noiseFields.length > 1 && (
+ removeNoise(index)}
+ onKeyDown={activateOnKey(() => removeNoise(index))}
+ />
+ )}
+
+
+
+ ({
+ value: v,
+ label: v,
+ }))}
+ />
+
+
+
+
+
+
+
+
+ ({
+ value: v,
+ label: v,
+ }))}
+ />
+
+
+ ))}
+
+
+ }
+ aria-label={t('add')}
+ onClick={() => appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })}
+ />
+
+ {t('pages.xray.outboundForm.overrideXrayPrivateIp')}
+
+
+ {finalRuleFields.map((field, index) => (
+
+
+
+ {t('pages.xray.outboundForm.ruleN', { n: index + 1 })}
+ removeFinalRule(index)}
+ onKeyDown={activateOnKey(() => removeFinalRule(index))}
+ />
+
+
+
+ ({
+ value: v,
+ label: v,
+ }))}
+ />
+
+
+ ({
+ value: v,
+ label: v,
+ }))}
+ />
+
+
+
+
+
+
+
+ {finalRulesValues[index]?.action === 'block' && (
+
+
+
+ )}
+
+ ))}
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/protocols/http.tsx b/frontend/src/pages/xray/outbounds/protocols/http.tsx
index 794e6aabb..badc9aeed 100644
--- a/frontend/src/pages/xray/outbounds/protocols/http.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/http.tsx
@@ -1,21 +1,22 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
export default function HttpFields() {
const { t } = useTranslation();
return (
<>
-
+
-
-
+
+
-
-
+
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/protocols/loopback.tsx b/frontend/src/pages/xray/outbounds/protocols/loopback.tsx
index 561460756..89ea24ee3 100644
--- a/frontend/src/pages/xray/outbounds/protocols/loopback.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/loopback.tsx
@@ -1,22 +1,30 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
+import { Controller, useFormContext } from 'react-hook-form';
-import SniffingFields from '@/lib/xray/forms/SniffingFields';
+import { FormField } from '@/components/form/rhf';
+import { SniffingField } from '@/lib/xray/forms/fields';
export default function LoopbackFields() {
const { t } = useTranslation();
- const form = Form.useFormInstance();
+ const { control } = useFormContext();
return (
<>
-
+
-
+
- (
+
+ )}
/>
>
);
diff --git a/frontend/src/pages/xray/outbounds/protocols/server-target.tsx b/frontend/src/pages/xray/outbounds/protocols/server-target.tsx
index a89f9b509..68c396d98 100644
--- a/frontend/src/pages/xray/outbounds/protocols/server-target.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/server-target.tsx
@@ -1,24 +1,28 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber } from 'antd';
+import { Input, InputNumber } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
export default function ServerTarget() {
const { t } = useTranslation();
return (
<>
-
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/protocols/shadowsocks.tsx b/frontend/src/pages/xray/outbounds/protocols/shadowsocks.tsx
index aa40766b4..7124aa8ab 100644
--- a/frontend/src/pages/xray/outbounds/protocols/shadowsocks.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/shadowsocks.tsx
@@ -1,9 +1,9 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { Input, InputNumber, Select, Switch } from 'antd';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { ShadowsocksOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
-import { antdRule } from '@/utils/zodForm';
import { SS_METHOD_OPTIONS } from '../outbound-form-constants';
@@ -11,30 +11,30 @@ export default function ShadowsocksFields() {
const { t } = useTranslation();
return (
<>
-
-
-
+
-
-
+
-
-
+
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/protocols/socks.tsx b/frontend/src/pages/xray/outbounds/protocols/socks.tsx
index 51ff74cd0..6ee7f9f9c 100644
--- a/frontend/src/pages/xray/outbounds/protocols/socks.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/socks.tsx
@@ -1,16 +1,18 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
export default function SocksFields() {
const { t } = useTranslation();
return (
<>
-
+
-
-
+
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/protocols/trojan.tsx b/frontend/src/pages/xray/outbounds/protocols/trojan.tsx
index 34d9a53ed..9780396d7 100644
--- a/frontend/src/pages/xray/outbounds/protocols/trojan.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/trojan.tsx
@@ -1,18 +1,18 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { TrojanOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
-import { antdRule } from '@/utils/zodForm';
export default function TrojanFields() {
const { t } = useTranslation();
return (
-
-
+
);
}
diff --git a/frontend/src/pages/xray/outbounds/protocols/vless.tsx b/frontend/src/pages/xray/outbounds/protocols/vless.tsx
index 444a31c2f..4ed04eb1f 100644
--- a/frontend/src/pages/xray/outbounds/protocols/vless.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/vless.tsx
@@ -1,33 +1,33 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
import {
VlessOutboundFormSettingsSchema,
VmessOutboundFormSettingsSchema,
} from '@/schemas/forms/outbound-form';
-import { antdRule } from '@/utils/zodForm';
export default function VlessFields() {
const { t } = useTranslation();
return (
<>
-
-
-
+
-
-
+
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/protocols/vmess.tsx b/frontend/src/pages/xray/outbounds/protocols/vmess.tsx
index 78b17ae1d..915e62b50 100644
--- a/frontend/src/pages/xray/outbounds/protocols/vmess.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/vmess.tsx
@@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, Select } from 'antd';
+import { Input, Select } from 'antd';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { VmessOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
-import { antdRule } from '@/utils/zodForm';
import { SECURITY_OPTIONS } from '../outbound-form-constants';
@@ -10,20 +10,20 @@ export default function VmessFields() {
const { t } = useTranslation();
return (
<>
-
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/protocols/wireguard.tsx b/frontend/src/pages/xray/outbounds/protocols/wireguard.tsx
index 5a868dacb..2b2dce0c0 100644
--- a/frontend/src/pages/xray/outbounds/protocols/wireguard.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/wireguard.tsx
@@ -1,147 +1,149 @@
import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, InputNumber, Select, Space, Switch, type FormInstance } from 'antd';
+import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext } from 'react-hook-form';
import { Wireguard } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { InputAddon } from '@/components/ui';
+import { FormField } from '@/components/form/rhf';
import { WireguardDomainStrategy } from '@/schemas/primitives';
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
-export default function WireguardFields({ form }: { form: FormInstance }) {
+function AllowedIPsList({ peerIndex }: { peerIndex: number }) {
const { t } = useTranslation();
+ const { control } = useFormContext();
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: `settings.peers.${peerIndex}.allowedIPs`,
+ });
return (
<>
-
+ {fields.map((field, ipIdx) => (
+
+
+
+
+ {fields.length > 1 && (
+ remove(ipIdx)}>
+
+
+ )}
+
+ ))}
+ }
+ aria-label={t('add')}
+ onClick={() => append('')}
+ />
+ >
+ );
+}
+
+export default function WireguardFields() {
+ const { t } = useTranslation();
+ const { control, setValue } = useFormContext();
+ const {
+ fields: peerFields,
+ append: appendPeer,
+ remove: removePeer,
+ } = useFieldArray({ control, name: 'settings.peers' });
+ return (
+ <>
+
-
+
-
+
-
+
}
aria-label={t('regenerate')}
onClick={() => {
const pair = Wireguard.generateKeypair();
- form.setFieldValue(['settings', 'secretKey'], pair.privateKey);
- form.setFieldValue(['settings', 'pubKey'], pair.publicKey);
+ setValue('settings.secretKey', pair.privateKey);
+ setValue('settings.pubKey', pair.publicKey);
}}
/>
-
+
-
-
+
+
({ value: s, label: s })),
]}
/>
-
-
+
+
-
-
+
-
-
+
+
+
+
+ }
+ aria-label={t('add')}
+ onClick={() =>
+ appendPeer({
+ publicKey: '',
+ psk: '',
+ allowedIPs: ['0.0.0.0/0', '::/0'],
+ endpoint: '',
+ keepAlive: 0,
+ })
+ }
+ />
-
- {(fields, { add, remove }) => (
- <>
-
- }
- aria-label={t('add')}
- onClick={() =>
- add({
- publicKey: '',
- psk: '',
- allowedIPs: ['0.0.0.0/0', '::/0'],
- endpoint: '',
- keepAlive: 0,
- })
- }
- />
-
- {fields.map((field, index) => (
-
- ))}
- >
- )}
-
+ {peerFields.map((field, index) => (
+
+ ))}
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/security/reality.tsx b/frontend/src/pages/xray/outbounds/security/reality.tsx
index db0cd1f4f..f7518b836 100644
--- a/frontend/src/pages/xray/outbounds/security/reality.tsx
+++ b/frontend/src/pages/xray/outbounds/security/reality.tsx
@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, Select } from 'antd';
+import { Input, Select } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
import { UTLS_OPTIONS } from '../outbound-form-constants';
@@ -7,42 +9,42 @@ export default function RealityForm() {
const { t } = useTranslation();
return (
<>
-
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/security/tls.tsx b/frontend/src/pages/xray/outbounds/security/tls.tsx
index 540ddb4cb..abab86f63 100644
--- a/frontend/src/pages/xray/outbounds/security/tls.tsx
+++ b/frontend/src/pages/xray/outbounds/security/tls.tsx
@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, Select } from 'antd';
+import { Input, Select } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
import { ALPN_OPTIONS, UTLS_OPTIONS } from '../outbound-form-constants';
@@ -7,13 +9,13 @@ export default function TlsForm() {
const { t } = useTranslation();
return (
<>
-
-
-
+
@@ -22,31 +24,31 @@ export default function TlsForm() {
placeholder={t('none')}
options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS]}
/>
-
-
+
-
-
+
-
-
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/transport/grpc.tsx b/frontend/src/pages/xray/outbounds/transport/grpc.tsx
index f031ead10..c1884c426 100644
--- a/frontend/src/pages/xray/outbounds/transport/grpc.tsx
+++ b/frontend/src/pages/xray/outbounds/transport/grpc.tsx
@@ -1,29 +1,31 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, Switch } from 'antd';
+import { Input, Switch } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
export default function GrpcForm() {
const { t } = useTranslation();
return (
<>
-
-
-
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/transport/httpupgrade.tsx b/frontend/src/pages/xray/outbounds/transport/httpupgrade.tsx
index ca9043222..89795b83a 100644
--- a/frontend/src/pages/xray/outbounds/transport/httpupgrade.tsx
+++ b/frontend/src/pages/xray/outbounds/transport/httpupgrade.tsx
@@ -1,30 +1,31 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
export default function HttpUpgradeForm() {
const { t } = useTranslation();
return (
<>
-
-
-
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/transport/hysteria.tsx b/frontend/src/pages/xray/outbounds/transport/hysteria.tsx
index 155a04ccd..2bf698465 100644
--- a/frontend/src/pages/xray/outbounds/transport/hysteria.tsx
+++ b/frontend/src/pages/xray/outbounds/transport/hysteria.tsx
@@ -1,134 +1,108 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
-const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
+const MASQ = ['streamSettings', 'hysteriaSettings', 'masquerade'];
+const MASQ_DOT = 'streamSettings.hysteriaSettings.masquerade';
-export default function HysteriaForm({ form }: { form: FormInstance }) {
+export default function HysteriaForm() {
const { t } = useTranslation();
+ const { control, setValue } = useFormContext();
+ const masquerade = useWatch({ control, name: MASQ_DOT }) as { type?: string } | undefined;
return (
<>
-
-
-
+
-
-
+
-
+
-
- {() => {
- const m = form.getFieldValue(MASQ_PATH);
- return (
-
- form.setFieldValue(
- MASQ_PATH,
- checked
- ? {
- type: '', dir: '', url: '',
- rewriteHost: false, insecure: false,
- content: '', headers: {}, statusCode: 0,
- }
- : undefined,
- )
+
+ setValue(
+ MASQ_DOT,
+ checked
+ ? {
+ type: '', dir: '', url: '',
+ rewriteHost: false, insecure: false,
+ content: '', headers: {}, statusCode: 0,
}
- />
- );
- }}
-
+ : undefined,
+ )
+ }
+ />
-
- {() => {
- const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
- if (!m) return null;
- return (
+ {masquerade && (
+ <>
+
+
+
+ {masquerade.type === 'proxy' && (
<>
-
+
+
+
-
-
- {m.type === 'proxy' && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
- {m.type === 'file' && (
-
-
-
- )}
- {m.type === 'string' && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
+
+
+
+
+
>
- );
- }}
-
+ )}
+ {masquerade.type === 'file' && (
+
+
+
+ )}
+ {masquerade.type === 'string' && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ )}
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/transport/kcp.tsx b/frontend/src/pages/xray/outbounds/transport/kcp.tsx
index 2bb968d30..23ba51f17 100644
--- a/frontend/src/pages/xray/outbounds/transport/kcp.tsx
+++ b/frontend/src/pages/xray/outbounds/transport/kcp.tsx
@@ -1,40 +1,42 @@
import { useTranslation } from 'react-i18next';
-import { Form, InputNumber } from 'antd';
+import { InputNumber } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
export default function KcpForm() {
const { t } = useTranslation();
return (
<>
-
+
-
-
+
+
-
-
+
-
-
+
-
-
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/transport/mux.tsx b/frontend/src/pages/xray/outbounds/transport/mux.tsx
index e9a512835..a0b4eb149 100644
--- a/frontend/src/pages/xray/outbounds/transport/mux.tsx
+++ b/frontend/src/pages/xray/outbounds/transport/mux.tsx
@@ -1,63 +1,58 @@
import { useTranslation } from 'react-i18next';
-import { Form, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
+import { FormField } from '@/components/form/rhf';
import { isMuxAllowed } from '../outbound-form-helpers';
interface MuxFormProps {
- form: FormInstance;
protocol: string;
network: string;
}
-export default function MuxForm({ form, protocol, network }: MuxFormProps) {
+export default function MuxForm({ protocol, network }: MuxFormProps) {
const { t } = useTranslation();
- const flow = (form.getFieldValue(['settings', 'flow']) ?? '') as string;
+ const { control } = useFormContext();
+ const flow = (useWatch({ control, name: 'settings.flow' }) ?? '') as string;
+ const muxEnabled = !!useWatch({ control, name: 'mux.enabled' });
if (!isMuxAllowed(protocol, flow, network)) return null;
return (
-
- {() => {
- const muxEnabled = !!form.getFieldValue(['mux', 'enabled']);
- return (
- <>
-
-
-
- {muxEnabled && (
- <>
-
-
-
-
-
-
-
- ({
- value: v,
- label: v,
- }))}
- />
-
- >
- )}
- >
- );
- }}
-
+ <>
+
+
+
+ {muxEnabled && (
+ <>
+
+
+
+
+
+
+
+ ({
+ value: v,
+ label: v,
+ }))}
+ />
+
+ >
+ )}
+ >
);
}
diff --git a/frontend/src/pages/xray/outbounds/transport/raw.tsx b/frontend/src/pages/xray/outbounds/transport/raw.tsx
index 18c646f7c..e98aa9187 100644
--- a/frontend/src/pages/xray/outbounds/transport/raw.tsx
+++ b/frontend/src/pages/xray/outbounds/transport/raw.tsx
@@ -1,136 +1,109 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, Switch, type FormInstance } from 'antd';
+import { Form, Input, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
+import { FormField } from '@/components/form/rhf';
-export default function RawForm({ form }: { form: FormInstance }) {
+export default function RawForm() {
const { t } = useTranslation();
+ const { control, setValue } = useFormContext();
+ const type = (useWatch({
+ control,
+ name: 'streamSettings.tcpSettings.header.type',
+ }) ?? 'none') as string;
return (
-
- {() => {
- const type =
- form.getFieldValue([
- 'streamSettings',
- 'tcpSettings',
- 'header',
- 'type',
- ]) ?? 'none';
- return (
- <>
-
-
- form.setFieldValue(
- ['streamSettings', 'tcpSettings', 'header'],
- checked
- ? {
- type: 'http',
- request: {
- version: '1.1',
- method: 'GET',
- path: ['/'],
- headers: {},
- },
- response: {
- version: '1.1',
- status: '200',
- reason: 'OK',
- headers: {},
- },
- }
- : { type: 'none' },
- )
+ <>
+
+
+ setValue(
+ 'streamSettings.tcpSettings.header',
+ checked
+ ? {
+ type: 'http',
+ request: {
+ version: '1.1',
+ method: 'GET',
+ path: ['/'],
+ headers: {},
+ },
+ response: {
+ version: '1.1',
+ status: '200',
+ reason: 'OK',
+ headers: {},
+ },
}
- />
-
- {type === 'http' && (
- <>
-
-
-
-
-
-
- ({ value: Array.isArray(v) ? v.join(',') : v })}
- getValueFromEvent={(e) => {
- const raw = (e?.target?.value ?? '') as string;
- const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
- return parts.length > 0 ? parts : ['/'];
- }}
- >
-
-
-
-
-
+ : { type: 'none' },
+ )
+ }
+ />
+
+ {type === 'http' && (
+ <>
+
+
+
+
+
+
+ (Array.isArray(v) ? v.join(',') : v),
+ output: (raw) => {
+ const parts = String(raw ?? '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+ return parts.length > 0 ? parts : ['/'];
+ },
+ }}
+ >
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
- >
- );
- }}
-
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ >
);
}
diff --git a/frontend/src/pages/xray/outbounds/transport/sockopt.tsx b/frontend/src/pages/xray/outbounds/transport/sockopt.tsx
index 4b9e705ef..0ea670d6b 100644
--- a/frontend/src/pages/xray/outbounds/transport/sockopt.tsx
+++ b/frontend/src/pages/xray/outbounds/transport/sockopt.tsx
@@ -1,225 +1,209 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
-import { CustomSockoptList } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
+import { SockoptCustomField } from '@/lib/xray/forms/fields';
import { DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION } from '@/schemas/primitives';
import { HappyEyeballsSchema, SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
export default function SockoptForm({
- form,
outboundTags = [],
}: {
- form: FormInstance;
outboundTags?: string[];
}) {
const { t } = useTranslation();
+ const { control, setValue } = useFormContext();
+ const sockopt = useWatch({ control, name: 'streamSettings.sockopt' });
+ const hasSockopt = !!sockopt;
+ const dialerProxy = (useWatch({ control, name: 'streamSettings.sockopt.dialerProxy' }) ?? '') as string;
+ const happyEyeballs = useWatch({ control, name: 'streamSettings.sockopt.happyEyeballs' });
+ const hasHe = happyEyeballs != null;
+ const dialerProxyOptions = Array.from(
+ new Set([...outboundTags, dialerProxy].filter(Boolean)),
+ ).map((tg) => ({ value: tg, label: tg }));
return (
-
- {() => {
- const hasSockopt = !!form.getFieldValue([
- 'streamSettings',
- 'sockopt',
- ]);
- const dialerProxy = (form.getFieldValue([
- 'streamSettings',
- 'sockopt',
- 'dialerProxy',
- ]) ?? '') as string;
- const dialerProxyOptions = Array.from(
- new Set([...outboundTags, dialerProxy].filter(Boolean)),
- ).map((tg) => ({ value: tg, label: tg }));
- return (
- <>
-
- {
- form.setFieldValue(
- ['streamSettings', 'sockopt'],
- checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
- );
- }}
- />
-
- {hasSockopt && (
- <>
-
-
-
-
- ({
- value: v,
- label: v,
- }))}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ({
- value: v,
- label: v,
- }))}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {() => {
- const he = form.getFieldValue([
- 'streamSettings', 'sockopt', 'happyEyeballs',
- ]);
- const hasHe = he != null;
- return (
- <>
-
- {
- form.setFieldValue(
- ['streamSettings', 'sockopt', 'happyEyeballs'],
- v ? HappyEyeballsSchema.parse({}) : undefined,
- );
- }}
- />
-
- {hasHe && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
- >
- );
- }}
-
-
- >
+ <>
+
+ {
+ setValue(
+ 'streamSettings.sockopt',
+ checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
+ );
+ }}
+ />
+
+ {hasSockopt && (
+ <>
+
+
+
+
+ ({
+ value: v,
+ label: v,
+ }))}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ({
+ value: v,
+ label: v,
+ }))}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ setValue(
+ 'streamSettings.sockopt.happyEyeballs',
+ v ? HappyEyeballsSchema.parse({}) : undefined,
+ );
+ }}
+ />
+
+ {hasHe && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ (
+
)}
- >
- );
- }}
-
+ />
+ >
+ )}
+ >
);
}
diff --git a/frontend/src/pages/xray/outbounds/transport/ws.tsx b/frontend/src/pages/xray/outbounds/transport/ws.tsx
index dff2bc2fd..feb248a56 100644
--- a/frontend/src/pages/xray/outbounds/transport/ws.tsx
+++ b/frontend/src/pages/xray/outbounds/transport/ws.tsx
@@ -1,30 +1,31 @@
import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber } from 'antd';
+import { Input, InputNumber } from 'antd';
import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
export default function WsForm() {
const { t } = useTranslation();
return (
<>
-
+
-
-
+
+
-
-
+
-
-
+
-
+
>
);
}
diff --git a/frontend/src/pages/xray/outbounds/transport/xhttp.tsx b/frontend/src/pages/xray/outbounds/transport/xhttp.tsx
index a1e0995a7..90f4f17de 100644
--- a/frontend/src/pages/xray/outbounds/transport/xhttp.tsx
+++ b/frontend/src/pages/xray/outbounds/transport/xhttp.tsx
@@ -1,151 +1,141 @@
import { useTranslation } from 'react-i18next';
-import { AutoComplete, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
import { MODE_OPTIONS } from '../outbound-form-constants';
interface XhttpFormProps {
- form: FormInstance;
onXmuxToggle: (checked: boolean) => void;
}
-export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
+function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise) {
+ return async (value: unknown): Promise => {
+ try {
+ await fn(undefined, value);
+ return true;
+ } catch (e) {
+ return (e as Error).message;
+ }
+ };
+}
+
+const XH = 'streamSettings.xhttpSettings';
+
+export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
const { t } = useTranslation();
+ const { control } = useFormContext();
+ const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined;
+ const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` });
+ const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as string | undefined;
+ const table = useWatch({ control, name: `${XH}.sessionIDTable` });
+ const seqPlacement = useWatch({ control, name: `${XH}.seqPlacement` }) as string | undefined;
+ const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined;
+ const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
+
return (
<>
-
+
-
-
+
+
-
-
+
+
-
-
+
-
-
+
-
+
{/* Padding obfs sub-section: gated by a Switch.
When on, four extra knobs (key/header/placement/
method) tune how Xray injects random padding to
disguise the post body shape. */}
-
-
-
- {() => {
- const obfs = !!form.getFieldValue([
- 'streamSettings', 'xhttpSettings', 'xPaddingObfsMode',
- ]);
- if (!obfs) return null;
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
- }}
-
+
+ {obfs && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
-
- prev?.streamSettings?.xhttpSettings?.mode !==
- curr?.streamSettings?.xhttpSettings?.mode
- }
+
- {() => {
- const mode = form.getFieldValue([
- 'streamSettings', 'xhttpSettings', 'mode',
- ]);
- return (
-
-
-
- );
- }}
-
+
+
{/* Session + sequence + uplinkData placements:
three orthogonal slots Xray uses to thread
request metadata through the transport
(path / header / cookie / query). Key field
only matters when placement is not 'path'. */}
-
@@ -159,54 +149,38 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
{ value: 'query', label: 'query' },
]}
/>
-
-
- {() => {
- const placement = form.getFieldValue([
- 'streamSettings', 'xhttpSettings', 'sessionIDPlacement',
- ]);
- if (!placement || placement === 'path') return null;
- return (
-
-
-
- );
- }}
-
-
+ {sessionPlacement && sessionPlacement !== 'path' && (
+
+
+
+ )}
+
({ value: v }))}
placeholder="Base62"
/>
-
-
- {() => {
- const table = form.getFieldValue([
- 'streamSettings', 'xhttpSettings', 'sessionIDTable',
- ]);
- if (!table) return null;
- return (
-
-
-
- );
- }}
-
-
+ {!!table && (
+
+
+
+ )}
+
@@ -220,168 +194,130 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
{ value: 'query', label: 'query' },
]}
/>
-
-
- {() => {
- const placement = form.getFieldValue([
- 'streamSettings', 'xhttpSettings', 'seqPlacement',
- ]);
- if (!placement || placement === 'path') return null;
- return (
-
-
-
- );
- }}
-
+
+ {seqPlacement && seqPlacement !== 'path' && (
+
+
+
+ )}
{/* Mode-conditional sub-sections. */}
-
- {() => {
- const mode = form.getFieldValue([
- 'streamSettings', 'xhttpSettings', 'mode',
- ]);
- if (mode !== 'packet-up' && mode !== 'auto') return null;
- return (
+ {(mode === 'packet-up' || mode === 'auto') && (
+ <>
+
+
+
+
+
+
+
+
+
+ {uplinkDataPlacement && uplinkDataPlacement !== 'body' && (
<>
-
-
-
-
+
+
-
-
-
-
-
-
- {() => {
- const place = form.getFieldValue([
- 'streamSettings', 'xhttpSettings', 'uplinkDataPlacement',
- ]);
- if (!place || place === 'body') return null;
- return (
- <>
-
-
-
-
-
-
- >
- );
- }}
-
+
>
- );
- }}
-
-
- {() => {
- const mode = form.getFieldValue([
- 'streamSettings', 'xhttpSettings', 'mode',
- ]);
- if (mode !== 'stream-up' && mode !== 'stream-one') return null;
- return (
-
-
-
- );
- }}
-
+ )}
+ >
+ )}
+ {(mode === 'stream-up' || mode === 'stream-one') && (
+
+
+
+ )}
{/* XMUX is the connection-multiplexing layer
xHTTP uses to fan out parallel requests over
a small pool of upstream connections. UI-only
toggle (enableXmux) hides the 6 nested knobs
when off. */}
- onXmuxToggle(v as boolean)}
>
-
-
-
- {() => {
- if (!form.getFieldValue([
- 'streamSettings', 'xhttpSettings', 'enableXmux',
- ])) return null;
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
- }}
-
+
+
+ {enableXmux && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
>
);
}
diff --git a/frontend/src/pages/xray/overrides/NordModal.tsx b/frontend/src/pages/xray/overrides/NordModal.tsx
index 8ba37cd48..534534f8d 100644
--- a/frontend/src/pages/xray/overrides/NordModal.tsx
+++ b/frontend/src/pages/xray/overrides/NordModal.tsx
@@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil } from '@/utils';
+import { FormField } from '@/components/form/rhf';
import './NordModal.css';
interface NordModalProps {
@@ -44,6 +46,22 @@ interface NordServer {
cityName?: string;
}
+interface NordFormValues {
+ token: string;
+ manualKey: string;
+ countryId: number | null;
+ cityId: number | null;
+ serverId: number | null;
+}
+
+const EMPTY: NordFormValues = {
+ token: '',
+ manualKey: '',
+ countryId: null,
+ cityId: null,
+ serverId: null,
+};
+
function loadColor(load: number): string {
if (load < 30) return 'green';
if (load < 70) return 'orange';
@@ -63,14 +81,12 @@ export default function NordModal({
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [nordData, setNordData] = useState(null);
- const [token, setToken] = useState('');
- const [manualKey, setManualKey] = useState('');
const [countries, setCountries] = useState([]);
const [cities, setCities] = useState([]);
const [servers, setServers] = useState([]);
- const [countryId, setCountryId] = useState(null);
- const [cityId, setCityId] = useState(null);
- const [serverId, setServerId] = useState(null);
+ const methods = useForm({ defaultValues: EMPTY });
+ const cityId = useWatch({ control: methods.control, name: 'cityId' });
+ const serverId = useWatch({ control: methods.control, name: 'serverId' });
const nordOutboundIndex = useMemo(() => {
const list = templateSettings?.outbounds;
@@ -84,8 +100,8 @@ export default function NordModal({
}, [cityId, servers]);
useEffect(() => {
- setServerId(filteredServers.length > 0 ? filteredServers[0].id : null);
- }, [filteredServers]);
+ methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
+ }, [filteredServers, methods]);
const fetchCountries = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/xray/nord/countries');
@@ -113,7 +129,7 @@ export default function NordModal({
async function login() {
setLoading(true);
try {
- const msg = await HttpUtil.post('/panel/api/xray/nord/reg', { token });
+ const msg = await HttpUtil.post('/panel/api/xray/nord/reg', { token: methods.getValues('token') });
if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj));
await fetchCountries();
@@ -126,7 +142,7 @@ export default function NordModal({
async function saveKey() {
setLoading(true);
try {
- const msg = await HttpUtil.post('/panel/api/xray/nord/setKey', { key: manualKey });
+ const msg = await HttpUtil.post('/panel/api/xray/nord/setKey', { key: methods.getValues('manualKey') });
if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj));
await fetchCountries();
@@ -144,14 +160,10 @@ export default function NordModal({
onRemoveOutbound(nordOutboundIndex);
onRemoveRoutingRules({ prefix: 'nord-' });
setNordData(null);
- setToken('');
- setManualKey('');
+ methods.reset(EMPTY);
setCountries([]);
setCities([]);
setServers([]);
- setCountryId(null);
- setCityId(null);
- setServerId(null);
}
} finally {
setLoading(false);
@@ -159,12 +171,11 @@ export default function NordModal({
}
async function fetchServers(newCountryId: number) {
- setCountryId(newCountryId);
setLoading(true);
setServers([]);
setCities([]);
- setServerId(null);
- setCityId(null);
+ methods.setValue('serverId', null);
+ methods.setValue('cityId', null);
try {
const msg = await HttpUtil.post('/panel/api/xray/nord/servers', { countryId: newCountryId });
if (!msg?.success || !msg.obj) return;
@@ -194,7 +205,8 @@ export default function NordModal({
}
function buildNordOutbound(): Record | null {
- const server = servers.find((s) => s.id === serverId);
+ const selectedServerId = methods.getValues('serverId');
+ const server = servers.find((s) => s.id === selectedServerId);
if (!server) return null;
const tech = server.technologies?.find((tt) => tt.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
@@ -244,6 +256,7 @@ export default function NordModal({
<>
{messageContextHolder}
+
{nordData == null ? (
-
- setToken(e.target.value)}
- />
- } onClick={login}>
- {t('login')}
-
-
+
+
+
+ } onClick={login}>
+ {t('login')}
+
),
},
@@ -281,16 +290,12 @@ export default function NordModal({
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
-
- setManualKey(e.target.value)}
- />
- } onClick={saveKey}>
- {t('save')}
-
-
+
+
+
+ } onClick={saveKey}>
+ {t('save')}
+
),
},
@@ -320,35 +325,34 @@ export default function NordModal({
{t('pages.xray.warp.settings')}
-
+ v ?? undefined }}
+ onAfterChange={(v) => fetchServers(v as number)}
+ >
fetchServers(v)}
options={countries.map((c) => ({
value: c.id,
label: `${c.name} (${c.code})`,
}))}
/>
-
+
{cities.length > 0 && (
-
+
({ value: c.id, label: c.name }))]}
/>
-
+
)}
{filteredServers.length > 0 && (
-
+
({
value: s.id,
label: `${s.cityName} ${s.name} ${s.hostname}`,
@@ -364,7 +368,7 @@ export default function NordModal({
),
}))}
/>
-
+
)}
@@ -392,6 +396,7 @@ export default function NordModal({
)}
>
)}
+
>
);
diff --git a/frontend/src/pages/xray/overrides/WarpModal.tsx b/frontend/src/pages/xray/overrides/WarpModal.tsx
index 8dd40b2b2..1698a0795 100644
--- a/frontend/src/pages/xray/overrides/WarpModal.tsx
+++ b/frontend/src/pages/xray/overrides/WarpModal.tsx
@@ -12,8 +12,10 @@ import {
Tag,
} from 'antd';
import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
+import { FormField } from '@/components/form/rhf';
import './WarpModal.css';
interface WarpModalProps {
@@ -51,6 +53,13 @@ interface WarpConfig {
};
}
+interface WarpFormValues {
+ warpPlus: string;
+ updateInterval: number;
+}
+
+const EMPTY: WarpFormValues = { warpPlus: '', updateInterval: 0 };
+
function addressesFor(addrs: { v4?: string; v6?: string }): string[] {
const out: string[] = [];
if (addrs.v4) out.push(`${addrs.v4}/32`);
@@ -79,10 +88,10 @@ export default function WarpModal({
const [loading, setLoading] = useState(false);
const [warpData, setWarpData] = useState(null);
const [warpConfig, setWarpConfig] = useState(null);
- const [warpPlus, setWarpPlus] = useState('');
- const [updateInterval, setUpdateInterval] = useState(0);
const [licenseError, setLicenseError] = useState('');
const [stagedOutbound, setStagedOutbound] = useState | null>(null);
+ const methods = useForm({ defaultValues: EMPTY });
+ const warpPlusValue = useWatch({ control: methods.control, name: 'warpPlus' }) ?? '';
const warpOutboundIndex = useMemo(() => {
const list = templateSettings?.outbounds;
@@ -132,12 +141,12 @@ export default function WarpModal({
}
const settingMsg = await HttpUtil.post>('/panel/api/setting/all');
if (settingMsg?.success && settingMsg.obj) {
- setUpdateInterval(Number(settingMsg.obj.warpUpdateInterval) || 0);
+ methods.setValue('updateInterval', Number(settingMsg.obj.warpUpdateInterval) || 0);
}
} finally {
setLoading(false);
}
- }, []);
+ }, [methods]);
useEffect(() => {
if (!open) return;
@@ -202,7 +211,7 @@ export default function WarpModal({
async function saveInterval() {
setLoading(true);
try {
- const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: updateInterval });
+ const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: methods.getValues('updateInterval') });
if (msg?.success) {
messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
}
@@ -212,15 +221,16 @@ export default function WarpModal({
}
async function updateLicense() {
- if (warpPlus.length < 26) return;
+ const licenseValue = methods.getValues('warpPlus');
+ if (licenseValue.length < 26) return;
setLoading(true);
setLicenseError('');
try {
- const msg = await HttpUtil.post('/panel/api/xray/warp/license', { license: warpPlus });
+ const msg = await HttpUtil.post('/panel/api/xray/warp/license', { license: licenseValue });
if (msg?.success && msg.obj) {
setWarpData(JSON.parse(msg.obj));
setWarpConfig(null);
- setWarpPlus('');
+ methods.setValue('warpPlus', '');
} else {
setLicenseError(msg?.msg || t('pages.xray.warp.licenseError'));
}
@@ -266,6 +276,7 @@ export default function WarpModal({
<>
{messageContextHolder}
+
{!hasWarp ? (
} onClick={register}>
{t('pages.xray.warp.createAccount')}
@@ -307,29 +318,26 @@ export default function WarpModal({
label: t('pages.xray.warp.licenseKeyLabel'),
children: (
-
- {
- setWarpPlus(e.target.value);
- setLicenseError('');
- }}
- />
-
-
- {t('update')}
-
- {licenseError && (
-
- )}
-
-
+ setLicenseError('')}
+ >
+
+
+
+
+ {t('update')}
+
+ {licenseError && (
+
+ )}
+
),
},
@@ -338,18 +346,17 @@ export default function WarpModal({
label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
children: (
-
- setUpdateInterval(Number(e.target.value))}
- />
-
- {t('save', 'Save')}
-
-
+ Number(v) }}
+ >
+
+
+
+ {t('save', 'Save')}
+
),
},
@@ -431,6 +438,7 @@ export default function WarpModal({
)}
>
)}
+
>
);
diff --git a/frontend/src/pages/xray/routing/RuleFormModal.tsx b/frontend/src/pages/xray/routing/RuleFormModal.tsx
index b0e92a89d..849678dbb 100644
--- a/frontend/src/pages/xray/routing/RuleFormModal.tsx
+++ b/frontend/src/pages/xray/routing/RuleFormModal.tsx
@@ -1,8 +1,10 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd';
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
+import { FormField } from '@/components/form/rhf';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
@@ -36,9 +38,7 @@ interface RuleFormModalProps {
onConfirm: (rule: Record) => void;
}
-type FormState = RuleFormValues;
-
-const initialForm = (): FormState => ({
+const initialForm = (): RuleFormValues => ({
enabled: true,
domain: '',
ip: '',
@@ -73,7 +73,7 @@ export default function RuleFormModal({
onConfirm,
}: RuleFormModalProps) {
const { t } = useTranslation();
- const [form, setForm] = useState(initialForm);
+ const methods = useForm({ defaultValues: initialForm() });
const isEdit = rule != null;
const { data: inboundOptions } = useInboundOptions();
@@ -82,7 +82,7 @@ export default function RuleFormModal({
useEffect(() => {
if (!open) return;
if (rule) {
- setForm({
+ methods.reset({
enabled: rule.enabled !== false,
domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
@@ -99,15 +99,14 @@ export default function RuleFormModal({
balancerTag: rule.balancerTag || '',
});
} else {
- setForm(initialForm());
+ methods.reset(initialForm());
}
- }, [open, rule]);
+ }, [open, rule, methods]);
- const update = (key: K, value: FormState[K]) =>
- setForm((prev) => ({ ...prev, [key]: value }));
+ const attrs = useWatch({ control: methods.control, name: 'attrs' }) ?? [];
function submit() {
- const validated = RuleFormSchema.safeParse(form);
+ const validated = RuleFormSchema.safeParse(methods.getValues());
if (!validated.success) return;
const v = validated.data;
const built: Record = {
@@ -154,167 +153,159 @@ export default function RuleFormModal({
onOk={submit}
onCancel={onClose}
>
-
-
- update('enabled', checked)}
- disabled={isApiRule(rule ?? {})}
- />
-
+
+
+
+
+
-
- {t('pages.xray.ruleForm.sourceIps')}
-
- }
- >
- update('sourceIP', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
-
+
+ {t('pages.xray.ruleForm.sourceIps')}
+
+ }
+ >
+
+
-
- {t('pages.xray.ruleForm.sourcePort')}
-
- }
- >
- update('sourcePort', e.target.value)} placeholder="53,443,1000-2000" />
-
+
+ {t('pages.xray.ruleForm.sourcePort')}
+
+ }
+ >
+
+
-
- {t('pages.xray.ruleForm.vlessRoute')}
-
- }
- >
- update('vlessRoute', e.target.value)} placeholder="53,443,1000-2000" />
-
+
+ {t('pages.xray.ruleForm.vlessRoute')}
+
+ }
+ >
+
+
-
- update('network', v)}
- options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))}
- />
-
+
+ ({ value: n, label: n || '(any)' }))} />
+
-
- update('protocol', v)}
- options={PROTOCOLS.map((p) => ({ value: p, label: p }))}
- />
-
+
+ ({ value: p, label: p }))} />
+
-
- } onClick={() => update('attrs', [...form.attrs, ['', '']])} />
-
-
- {form.attrs.map((attr, idx) => (
-
- {`${idx + 1}`}
- {
- const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
- update('attrs', next);
- }}
- />
- {
- const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
- update('attrs', next);
- }}
- />
- }
- onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
- />
-
- ))}
-
+
+ }
+ onClick={() => methods.setValue('attrs', [...attrs, ['', ''] as [string, string]])}
+ />
+
+
+ {attrs.map((attr, idx) => (
+
+ {`${idx + 1}`}
+ {
+ const next = attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
+ methods.setValue('attrs', next);
+ }}
+ />
+ {
+ const next = attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
+ methods.setValue('attrs', next);
+ }}
+ />
+ }
+ onClick={() => methods.setValue('attrs', attrs.filter((_, i) => i !== idx))}
+ />
+
+ ))}
+
-
- IP
-
- }
- >
- update('ip', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
-
+
+ IP
+
+ }
+ >
+
+
-
- {t('domainName')}
-
- }
- >
- update('domain', e.target.value)} placeholder="google.com, geosite:cn" />
-
+
+ {t('domainName')}
+
+ }
+ >
+
+
-
- {t('pages.xray.ruleForm.user')}
-
- }
- >
- update('user', e.target.value)} placeholder="email address" />
-
+
+ {t('pages.xray.ruleForm.user')}
+
+ }
+ >
+
+
-
- {t('pages.inbounds.port')}
-
- }
- >
- update('port', e.target.value)} placeholder="53,443,1000-2000" />
-
+
+ {t('pages.inbounds.port')}
+
+ }
+ >
+
+
-
- update('inboundTag', v)}
- options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
- />
-
+
+ ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
+ />
+
-
- update('outboundTag', v)}
- options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
- />
-
+
+ ({ value: tag, label: tag || '(none)' }))} />
+
-
- {t('pages.xray.ruleForm.balancerTag')}
-
- }
- >
- update('balancerTag', v)}
- options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
- />
-
-
+
+ {t('pages.xray.ruleForm.balancerTag')}
+
+ }
+ >
+ ({ value: tag, label: tag || '(none)' }))} />
+
+
+
);
}
diff --git a/frontend/src/test/http-init-msw.test.ts b/frontend/src/test/http-init-msw.test.ts
new file mode 100644
index 000000000..a70c7f00e
--- /dev/null
+++ b/frontend/src/test/http-init-msw.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { http, HttpResponse } from 'msw';
+
+import { httpRequest, setupHttp } from '@/api/http-init';
+
+import { CSRF_TOKEN } from './msw/handlers';
+import { server } from './msw/server';
+
+const ORIGIN = 'http://localhost';
+
+describe('httpRequest against the MSW-mocked network', () => {
+ beforeEach(() => {
+ vi.stubGlobal('document', { querySelector: () => null });
+ window.X_UI_BASE_PATH = ORIGIN;
+ setupHttp();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ delete window.X_UI_BASE_PATH;
+ });
+
+ it('fetches a CSRF token, then refetches and retries once after a 403', async () => {
+ let posts = 0;
+ const sentTokens: (string | null)[] = [];
+ server.use(
+ http.get(`${ORIGIN}/csrf-token`, () => HttpResponse.json({ success: true, obj: CSRF_TOKEN })),
+ http.post(`${ORIGIN}/panel/api/test`, ({ request }) => {
+ posts += 1;
+ sentTokens.push(request.headers.get('X-CSRF-Token'));
+ if (posts === 1) return new HttpResponse(null, { status: 403 });
+ return HttpResponse.json({ success: true, obj: 'saved' });
+ }),
+ );
+
+ const res = await httpRequest('POST', '/panel/api/test', { hello: 'world' });
+
+ expect(posts).toBe(2);
+ expect(sentTokens).toEqual([CSRF_TOKEN, CSRF_TOKEN]);
+ expect(res.status).toBe(200);
+ expect(res.data).toEqual({ success: true, obj: 'saved' });
+ });
+
+ it('resolves a safe GET without requesting a CSRF token', async () => {
+ let csrfHits = 0;
+ server.use(
+ http.get(`${ORIGIN}/csrf-token`, () => {
+ csrfHits += 1;
+ return HttpResponse.json({ success: true, obj: CSRF_TOKEN });
+ }),
+ http.get(`${ORIGIN}/panel/api/status`, () => HttpResponse.json({ success: true, obj: { up: true } })),
+ );
+
+ const res = await httpRequest('GET', '/panel/api/status');
+
+ expect(csrfHits).toBe(0);
+ expect(res.status).toBe(200);
+ expect(res.data).toEqual({ success: true, obj: { up: true } });
+ });
+});
diff --git a/frontend/src/test/inbound-form-blocks.test.tsx b/frontend/src/test/inbound-form-blocks.test.tsx
index 26e9df950..beb6ddfdf 100644
--- a/frontend/src/test/inbound-form-blocks.test.tsx
+++ b/frontend/src/test/inbound-form-blocks.test.tsx
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
-import { Form, type FormInstance } from 'antd';
+import { Form } from 'antd';
import type { ReactNode } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
import {
GrpcForm,
@@ -17,61 +18,62 @@ import { renderWithProviders, fieldLabels } from './test-utils';
function FormHarness({
children,
- initialValues,
+ defaultValues,
}: {
- children: (form: FormInstance) => ReactNode;
- initialValues?: Record;
+ children: ReactNode;
+ defaultValues?: Record;
}) {
- const [form] = Form.useForm();
- return {children(form)} ;
+ const methods = useForm({ defaultValues: defaultValues as never });
+ return (
+
+ {children}
+
+ );
}
-function renderInForm(
- node: (form: FormInstance) => ReactNode,
- initialValues?: Record,
-) {
- return renderWithProviders({node} );
+function renderInForm(node: ReactNode, defaultValues?: Record) {
+ return renderWithProviders({node} );
}
const noop = () => {};
describe('inbound transport forms', () => {
it('RawForm field structure is stable', () => {
- renderInForm(() => );
+ renderInForm( );
expect(fieldLabels()).toMatchSnapshot();
});
it('WsForm field structure is stable', () => {
- renderInForm(() => );
+ renderInForm( );
expect(fieldLabels()).toMatchSnapshot();
});
it('GrpcForm field structure is stable', () => {
- renderInForm(() => );
+ renderInForm( );
expect(fieldLabels()).toMatchSnapshot();
});
it('KcpForm field structure is stable', () => {
- renderInForm(() => );
+ renderInForm( );
expect(fieldLabels()).toMatchSnapshot();
});
it('HttpUpgradeForm field structure is stable', () => {
- renderInForm(() => );
+ renderInForm( );
expect(fieldLabels()).toMatchSnapshot();
});
it('XhttpForm field structure is stable', () => {
- renderInForm((form) => );
+ renderInForm( );
expect(fieldLabels()).toMatchSnapshot();
});
it('SockoptForm field structure is stable (server-side fields only)', () => {
- // The inbound sockopt form shows only server/listening-side fields;
- // outbound-only fields (dialerProxy, domainStrategy, interface,
- // addressPortStrategy, happyEyeballs, tcpMptcp) live in the outbound form.
+ /* The inbound sockopt form shows only server/listening-side fields;
+ outbound-only fields (dialerProxy, domainStrategy, interface,
+ addressPortStrategy, happyEyeballs, tcpMptcp) live in the outbound form. */
renderInForm(
- () => ,
+ ,
{ streamSettings: { sockopt: { mark: 0 } } },
);
expect(fieldLabels()).toMatchSnapshot();
@@ -80,7 +82,7 @@ describe('inbound transport forms', () => {
describe('inbound security forms', () => {
it('TlsForm field structure is stable', () => {
- renderInForm(() => (
+ renderInForm(
{
pinFromRemote={noop}
getNewEchCert={noop}
clearEchCert={noop}
- />
- ));
+ />,
+ );
expect(fieldLabels()).toMatchSnapshot();
});
it('RealityForm field structure is stable', () => {
- renderInForm(() => (
+ renderInForm(
{
clearRealityKeypair={noop}
genMldsa65={noop}
clearMldsa65={noop}
- />
- ));
+ />,
+ );
expect(fieldLabels()).toMatchSnapshot();
});
});
diff --git a/frontend/src/test/msw/handlers.ts b/frontend/src/test/msw/handlers.ts
new file mode 100644
index 000000000..3fef797ae
--- /dev/null
+++ b/frontend/src/test/msw/handlers.ts
@@ -0,0 +1,7 @@
+import { http, HttpResponse } from 'msw';
+
+export const CSRF_TOKEN = 'test-csrf-token';
+
+export const handlers = [
+ http.get('*/csrf-token', () => HttpResponse.json({ success: true, obj: CSRF_TOKEN })),
+];
diff --git a/frontend/src/test/msw/server.ts b/frontend/src/test/msw/server.ts
new file mode 100644
index 000000000..5ac9204f2
--- /dev/null
+++ b/frontend/src/test/msw/server.ts
@@ -0,0 +1,5 @@
+import { setupServer } from 'msw/node';
+
+import { handlers } from './handlers';
+
+export const server = setupServer(...handlers);
diff --git a/frontend/src/test/rhf-form-field.test.tsx b/frontend/src/test/rhf-form-field.test.tsx
new file mode 100644
index 000000000..6640919a2
--- /dev/null
+++ b/frontend/src/test/rhf-form-field.test.tsx
@@ -0,0 +1,94 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { FormProvider } from 'react-hook-form';
+import { Form, Input, InputNumber, Switch } from 'antd';
+import { z } from 'zod';
+
+import { FormField, useZodForm } from '@/components/form/rhf';
+
+const GB = 1024 * 1024 * 1024;
+
+const Schema = z.object({
+ email: z.string().min(1, 'email-required'),
+ enabled: z.boolean(),
+ slug: z.string(),
+ bytes: z.number(),
+});
+
+type Values = z.infer;
+
+function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) {
+ const methods = useZodForm(Schema, {
+ defaultValues: { email: '', enabled: false, slug: '', bytes: 2 * GB },
+ });
+ return (
+
+
+
+
+
+
+
+
+ String(v).toLowerCase() }}>
+
+
+ (typeof v === 'number' ? v / GB : v),
+ output: (v) => (typeof v === 'number' ? v * GB : 0),
+ }}
+ >
+
+
+ Save
+
+
+ );
+}
+
+describe('FormField', () => {
+ it('submits values normalized from the Ant Design inputs', async () => {
+ const onSubmit = vi.fn();
+ render( );
+
+ fireEvent.change(screen.getByLabelText('email'), { target: { value: 'a@b.com' } });
+ fireEvent.click(screen.getByLabelText('enabled'));
+ fireEvent.change(screen.getByLabelText('slug'), { target: { value: 'HELLO' } });
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit.mock.calls[0][0]).toMatchObject({
+ email: 'a@b.com',
+ enabled: true,
+ slug: 'hello',
+ bytes: 2 * GB,
+ });
+ });
+
+ it('applies the transform output before storing the value', async () => {
+ const onSubmit = vi.fn();
+ render( );
+
+ fireEvent.change(screen.getByLabelText('email'), { target: { value: 'x' } });
+ const bytes = screen.getByLabelText('bytes');
+ fireEvent.change(bytes, { target: { value: '5' } });
+ fireEvent.blur(bytes);
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit.mock.calls[0][0].bytes).toBe(5 * GB);
+ });
+
+ it('surfaces a resolver validation error as help text and blocks submit', async () => {
+ const onSubmit = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByText('Save'));
+
+ await screen.findByText('email-required');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/test/setup.msw.ts b/frontend/src/test/setup.msw.ts
new file mode 100644
index 000000000..dc08aeb79
--- /dev/null
+++ b/frontend/src/test/setup.msw.ts
@@ -0,0 +1,7 @@
+import { afterAll, afterEach, beforeAll } from 'vitest';
+
+import { server } from './msw/server';
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
diff --git a/frontend/src/utils/zodForm.ts b/frontend/src/utils/zodForm.ts
deleted file mode 100644
index cd335e509..000000000
--- a/frontend/src/utils/zodForm.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { Rule } from 'antd/es/form';
-import type { TFunction } from 'i18next';
-import type { z } from 'zod';
-
-export function antdRule(schema: T, t: TFunction): Rule {
- return {
- validator: async (_rule, value) => {
- const result = schema.safeParse(value);
- if (result.success) return;
- const issue = result.error.issues[0];
- const key = issue?.message ?? 'validation.invalid';
- throw new Error(t(key, { defaultValue: key }));
- },
- };
-}
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 1cab8b7da..4b8e836cd 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -19,7 +19,7 @@ export default defineConfig({
name: 'unit',
include: ['src/test/**/*.test.ts'],
environment: 'node',
- setupFiles: ['./src/test/setup.ts'],
+ setupFiles: ['./src/test/setup.ts', './src/test/setup.msw.ts'],
},
},
{