Frontend dev tooling (Husky, lint-staged, MSW, Storybook) + full React Hook Form migration (#5859)

* chore(frontend): add husky + lint-staged pre-commit gate

Wire a local pre-commit gate that runs eslint --fix on staged
frontend TypeScript via lint-staged. Because the only package.json
lives in frontend/ while the git root is one level up, the prepare
script installs husky hooks at frontend/.husky from the repo root
(cd .. && husky frontend/.husky), and the pre-commit hook cd's into
frontend/ before invoking lint-staged so node_modules resolves.

* test(frontend): add MSW request mocking

Add Mock Service Worker so tests can exercise the real http-init.ts
request pipeline (CSRF acquisition, 403 refetch-and-retry, body
parsing) instead of only stubbing HttpUtil. A node setupServer is
started for the vitest unit project with onUnhandledRequest bypass so
the existing HttpUtil spies and 55 component tests are untouched; the
browser worker is copied to public/ for Storybook and dev use.

* chore(frontend): add Storybook + component stories

Set up Storybook 10 on the React-Vite builder (compatible with the
pinned Vite 8.1.3 and React 19). The preview decorator mirrors the
vitest component harness: an Ant Design ConfigProvider with a
light/dark toolbar toggle and an en-US i18next instance. main.ts
neutralizes the app vite config bits that do not belong in a component
workshop (the three-entry rollup input, renderBuiltUrl, and the shared
dist outDir) so build-storybook can never clobber internal/web/dist.
Seeds stories across the presentational library (viz, ui, clients,
feedback). build-storybook is a local tool and is not wired into the
CI gate.

* feat(frontend): add React Hook Form primitives

Introduce the shared RHF layer that AntD inputs bind through, ahead of
migrating the forms off Ant Design's Form store:
- FormField wraps a Controller in an Ant Design Form.Item shell,
  reconciling the value/onChange shapes of Input, Switch, InputNumber,
  Select and friends via normalizeAntdOnChange, with input/output
  transforms and Zod-issue-key error messages resolved through t().
- useZodForm wires zodResolver (Zod 4) with the AntD-matching modes
  (validate on submit, then live) and shouldUnregister false so hidden
  and unmounted-tab fields keep their values.
- rhfZodValidate covers the rare per-field rule sites.
Covered by a FormField test exercising normalization, transforms, and
resolver error surfacing.

* refactor(frontend): migrate Pattern-B leaf forms to React Hook Form

Move the controlled-useState leaf forms onto RHF via the FormField
primitive, keeping Ant Design components and each form's exact submit
behaviour (same safeParse, same toast on the first Zod issue, same
payload building):
- clients: ClientBulkAdjustModal, BulkAddToGroupModal, ClientBulkAddModal
- xray: RuleFormModal, BalancerFormModal, WarpModal, NordModal

Multi-control widgets that don't fit a single input (inbound dual
select, subId regen, expiry branches, the balancer tag warning) stay as
explicit Controller/setValue. Derived visibility now reads live values
through useWatch. FormField gains a required prop so migrated fields keep
their required-asterisk affordance.

Settings tabs are intentionally excluded: they are control-panel
components that live-patch a parent AllSetting via SettingListItem, not
Ant Design Form submit-forms.

* refactor(frontend): migrate LoginPage to React Hook Form

Replace the Ant Design Form store + antdRule per-field validation with
useForm + FormField. The AntD Form stays as the layout/submit wrapper,
now driving methods.handleSubmit(onSubmit) via onFinish. Username and
password validate through rhfZodValidate(LoginFormSchema.shape.*); the
two-factor field keeps its conditional required rule (only registered
when 2FA is enabled). Submit posts the same values to /login.

* refactor(frontend): migrate ClientFormModal to React Hook Form

Move the client add/edit form off controlled useState onto RHF while
preserving exact submit behaviour (same ClientFormSchema /
ClientCreateFormSchema safeParse, same toast, same payload + attach/
detach diff + external-links build). expiryDate is stored as an epoch
number (never a Dayjs) to survive RHF's value cloning, converted at the
DateTimePicker boundary. externalLinks uses useFieldArray with stable
ids. inboundIds and the derived show*/ss2022 visibility read live via
useWatch. Space.Compact button-group widgets stay manual Controllers so
the joined borders keep working.

* refactor(frontend): migrate Node and DNS modals to React Hook Form

Both are self-contained Pattern-A forms (no shared fragments). Replace
Form.useForm with useForm + FormProvider, Form.useWatch with useWatch,
setFieldValue with setValue, and partial validateFields([...]) with
methods.trigger([...]). Per-field antdRule becomes rhfZodValidate rules;
the Node scheme->tlsVerify cascade moves to FormField onAfterChange; the
DNS domains/expectIPs/unexpectIPs string arrays are driven by
useWatch + setValue. Submit runs through handleSubmit on the modal OK
button, preserving each form's exact validation, payload build, and
save/onConfirm behaviour.

* refactor(frontend): migrate HostFormModal to React Hook Form

The host external-proxy editor's outer form moves to useForm +
FormProvider. Security/tab visibility reads via useWatch; the three
json-form editors (HostMuxForm/HostSockoptForm/HostFinalMaskForm) are
bound as value/onChange black boxes through a Controller (their own
internal forms are unchanged). remark/inboundId keep their validation
via rhfZodValidate; submit runs through handleSubmit and builds the
same payload (isDisabled = !enable) and save call.

* refactor(frontend): migrate OutboundFormModal + fragments to React Hook Form

Move the outbound form cluster off Ant Design's Form store onto RHF.
The parent uses useForm + FormProvider with a watch() subscription for
the protocol reseed cascade and setValue-based network/security/xmux
cascades; the JSON<->Basic bridge and the formValuesToWirePayload
submit are preserved exactly. Every outbound transport/protocol/security
fragment now binds through FormField/useWatch via context.

The shared config editors stay untouched and are bound through small
value/onChange adapters (src/lib/xray/forms/fields: FinalMaskField,
SniffingField, SockoptCustomField) via Controller; HeaderMapEditor binds
directly. The host json-form wrappers that reuse the outbound MuxForm/
SockoptForm (HostMuxForm, HostSockoptForm, OutboundSubtreeJsonForm) move
to a local RHF provider to match. Outbound render/link tests pass
unchanged.

* refactor(frontend): migrate InboundFormModal + fragments to React Hook Form

Move the inbound add/edit form (the largest form in the panel) and its
transport/protocol/security fragments off Ant Design's Form store onto
RHF, mirroring the outbound migration. The parent uses useForm +
FormProvider with a watch() subscription for the protocol reseed
cascade (type==='change' guard so programmatic resets don't reseed) and
setValue-based network/security cascades; useSecurityActions drives the
TLS/Reality keypair + scan through setValue. Hidden pass-through
Form.Items are dropped (their values ride in the reset object and
survive via shouldUnregister:false), so getValues() still returns the
settings.clients subtree untouched. accounts / certificates / tun lists
use useFieldArray; the shared FinalMask/Sniffing/Sockopt editors bind
through the value/onChange adapters.

Submit keeps the manual InboundFormSchema.safeParse + formatInboundValidation
toast + formValuesToWirePayload exactly. The golden link/full fixtures
pass byte-for-byte, confirming identical wire output. inbound-form-blocks
test harness rewritten from a Form.useForm harness to an RHF provider.

* refactor(frontend): retire antdRule; document the RHF form pattern

All forms now build on React Hook Form, so the AntD-Form Zod adapter
antdRule (src/utils/zodForm.ts) has no remaining callers — remove it.
Update frontend/CLAUDE.md: forms use useZodForm + FormField from
components/form/rhf with zodResolver/rhfZodValidate validation; AntD
<Form> is layout-only; the shared FinalMask/Sniffing/Sockopt editors
stay AntD islands wrapped as value/onChange adapters bound via a
Controller.

* chore(frontend): cover esbuild in the allowScripts allowlist

esbuild (pulled in transitively by Vite/Vitest/Storybook) ships a
postinstall that npm's allow-scripts flags as uncovered on every
install. Its platform binary is delivered through the @esbuild/<platform>
optionalDependencies, so the postinstall isn't needed here; deny it like
the other entries to silence the warning.

* fix(frontend): restore label layout in Sniffing/FinalMask field adapters

The value/onChange adapters that wrap the shared SniffingFields and
FinalMaskForm editors put them in their own isolated AntD Form, but that
Form was missing the label layout the fields used to inherit from the
inbound/outbound parent form. Their labels rendered full-width instead
of the compact right-aligned column, so the Sniffing tab and the TCP
Masks / QUIC Params sections looked broken. Give both adapter forms the
same colon=false, labelCol/wrapperCol span 8/14, labelWrap layout.

* ci: add least-privilege permissions to Docs CI workflow

The docs-ci workflow had no explicit permissions block, so it inherited
the repository default for GITHUB_TOKEN. The build job only checks out
and builds the docs, so restrict it to contents: read, resolving the
CodeQL actions/missing-workflow-permissions alert.
This commit is contained in:
Sanaei
2026-07-08 13:28:37 +02:00
committed by GitHub
parent 8ee79cf447
commit 61e12e4c29
98 changed files with 9496 additions and 5089 deletions
+3
View File
@@ -11,6 +11,9 @@ on:
- 'docs/**' - 'docs/**'
- '.github/workflows/docs-ci.yml' - '.github/workflows/docs-ci.yml'
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: docs working-directory: docs
+1
View File
@@ -3,3 +3,4 @@ node_modules/
*.log *.log
*.tsbuildinfo *.tsbuildinfo
coverage/ coverage/
storybook-static/
+1
View File
@@ -0,0 +1 @@
cd frontend && npx lint-staged
+25
View File
@@ -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;
+60
View File
@@ -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 (
<ConfigProvider theme={{ algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm }}>
<div style={{ padding: 24, minWidth: 320 }}>
<Story />
</div>
</ConfigProvider>
);
};
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;
+11 -4
View File
@@ -27,12 +27,19 @@ The `@` import alias maps to `src/`.
`src/lib/xray/`. HTTP goes through `HttpUtil` in `src/utils/index.ts`. `src/lib/xray/`. HTTP goes through `HttpUtil` in `src/utils/index.ts`.
## Rules ## 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. - Function components + hooks only; no class components.
- No `//` line comments in committed TS/TSX. HTML comments are fine. - No `//` line comments in committed TS/TSX. HTML comments are fine.
- TS strict; `no-explicit-any` is an error. Validate form fields with - TS strict; `no-explicit-any` is an error. Build forms with `useZodForm` +
`antdRule(Schema.shape.field, t)` from `@/utils/zodForm`, not inline `FormField` from `@/components/form/rhf` (wrap the tree in `FormProvider`);
`z.string()`. 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 `<Form>` stays
only as a layout wrapper. Complex shared config editors (FinalMask / Sniffing /
Sockopt) remain AntD-`Form` islands wrapped as value/onChange adapters in
`src/lib/xray/forms/fields/`, bound via a `Controller`.
- New `g.POST`/`g.GET` route => add it to `src/pages/api-docs/endpoints.ts`, - New `g.POST`/`g.GET` route => add it to `src/pages/api-docs/endpoints.ts`,
then `npm run gen`. then `npm run gen`.
- i18n strings live in `internal/web/translation/<locale>.json`, NOT under - i18n strings live in `internal/web/translation/<locale>.json`, NOT under
+3469
View File
File diff suppressed because it is too large Load Diff
+24 -2
View File
@@ -16,14 +16,21 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"gen": "npm run gen:zod && npm run gen:api", "gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs", "gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen" "gen:zod": "cd .. && go run ./tools/openapigen",
"prepare": "cd .. && husky frontend/.husky || true"
},
"lint-staged": {
"src/**/*.{ts,tsx}": "eslint --fix"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.4.0",
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2",
@@ -35,6 +42,7 @@
"persian-calendar-suite": "^1.5.5", "persian-calendar-suite": "^1.5.5",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.1",
"swagger-ui-react": "^5.32.8", "swagger-ui-react": "^5.32.8",
@@ -43,6 +51,9 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.4.6",
"@storybook/addon-docs": "^10.4.6",
"@storybook/react-vite": "^10.4.6",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
@@ -54,7 +65,11 @@
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.7.0", "globals": "^17.7.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"msw": "^2.14.7",
"storybook": "^10.4.6",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.62.1", "typescript-eslint": "^8.62.1",
"vite": "8.1.3", "vite": "8.1.3",
@@ -79,6 +94,13 @@
"@tree-sitter-grammars/tree-sitter-yaml": false, "@tree-sitter-grammars/tree-sitter-yaml": false,
"tree-sitter": false, "tree-sitter": false,
"core-js-pure": false, "core-js-pure": false,
"tree-sitter-json": false "tree-sitter-json": false,
"msw": false,
"esbuild": false
},
"msw": {
"workerDirectory": [
"public"
]
} }
} }
+349
View File
@@ -0,0 +1,349 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.14.7'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ClientSpeedTag } from './ClientSpeedTag';
const meta = {
title: 'Clients/ClientSpeedTag',
component: ClientSpeedTag,
tags: ['autodocs'],
} satisfies Meta<typeof ClientSpeedTag>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Active: Story = {
args: { speed: { up: 1_450_000, down: 8_900_000 } },
};
export const Idle: Story = {
args: { speed: { up: 0, down: 0 } },
};
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ConfigBlock from './ConfigBlock';
const meta = {
title: 'Clients/ConfigBlock',
component: ConfigBlock,
tags: ['autodocs'],
parameters: { layout: 'padded' },
} satisfies Meta<typeof ConfigBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
const sampleLink = 'vless://11112222-3333-4444-5555-666677778888@panel.example.com:443'
+ '?type=ws&security=tls&path=%2Fpath#example-node';
export const Collapsed: Story = {
args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt' },
};
export const Expanded: Story = {
args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt', defaultOpen: true },
};
export const WithoutQr: Story = {
args: { label: 'trojan', text: sampleLink, fileName: 'client-config.txt', showQr: false, tagColor: 'geekblue' },
};
@@ -0,0 +1,70 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from 'antd';
import PromptModal from './PromptModal';
const meta = {
title: 'Feedback/PromptModal',
component: PromptModal,
tags: ['autodocs'],
} satisfies Meta<typeof PromptModal>;
export default meta;
type Story = StoryObj<typeof meta>;
function InputDemo() {
const [open, setOpen] = useState(false);
const [value, setValue] = useState('');
return (
<>
<Button type="primary" onClick={() => setOpen(true)}>Rename client</Button>
<div style={{ marginTop: 12 }}>Last confirmed: {value || '—'}</div>
<PromptModal
open={open}
title="Enter a new name"
initialValue={value}
onClose={() => setOpen(false)}
onConfirm={(next) => {
setValue(next);
setOpen(false);
}}
/>
</>
);
}
function TextareaDemo() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Edit note</Button>
<PromptModal
open={open}
type="textarea"
title="Edit note"
initialValue={'line one\nline two'}
onClose={() => setOpen(false)}
onConfirm={() => setOpen(false)}
/>
</>
);
}
const placeholderArgs = {
open: false,
title: '',
onClose: () => undefined,
onConfirm: () => undefined,
};
export const Input: Story = {
args: placeholderArgs,
render: () => <InputDemo />,
};
export const Textarea: Story = {
args: placeholderArgs,
render: () => <TextareaDemo />,
};
@@ -0,0 +1,51 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from 'antd';
import TextModal from './TextModal';
const meta = {
title: 'Feedback/TextModal',
component: TextModal,
tags: ['autodocs'],
} satisfies Meta<typeof TextModal>;
export default meta;
type Story = StoryObj<typeof meta>;
const jsonSample = JSON.stringify({ outbounds: [{ protocol: 'vless', tag: 'proxy' }] }, null, 2);
function Demo({ json, fileName }: { json?: boolean; fileName?: string }) {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Show config</Button>
<TextModal
open={open}
title="Client configuration"
content={json ? jsonSample : 'vless://uuid@example.com:443#node'}
fileName={fileName}
json={json}
onClose={() => setOpen(false)}
/>
</>
);
}
const placeholderArgs = {
open: false,
title: '',
content: '',
onClose: () => undefined,
};
export const PlainText: Story = {
args: placeholderArgs,
render: () => <Demo fileName="client.txt" />,
};
export const Json: Story = {
args: placeholderArgs,
render: () => <Demo json fileName="config.json" />,
};
@@ -0,0 +1,93 @@
import { cloneElement } from 'react';
import type { CSSProperties, ReactElement, ReactNode } from 'react';
import { Controller } from 'react-hook-form';
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
import { Form } from 'antd';
import { useTranslation } from 'react-i18next';
import { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
import { toDotted, type FieldName } from './toDotted';
interface FormFieldTransform {
input?: (value: unknown) => unknown;
output?: (eventValue: unknown) => unknown;
}
export interface FormFieldProps<T extends FieldValues = FieldValues> {
name: FieldName;
control?: Control<T>;
label?: ReactNode;
tooltip?: ReactNode;
extra?: ReactNode;
valueProp?: ValueProp;
transform?: FormFieldTransform;
onAfterChange?: (value: unknown) => void;
rules?: ControllerProps<T>['rules'];
required?: boolean;
noStyle?: boolean;
className?: string;
style?: CSSProperties;
children: ReactElement;
}
export function FormField<T extends FieldValues = FieldValues>({
name,
control,
label,
tooltip,
extra,
valueProp = 'value',
transform,
onAfterChange,
rules,
required,
noStyle,
className,
style,
children,
}: FormFieldProps<T>) {
const { t } = useTranslation();
const dottedName = toDotted(name) as Path<T>;
return (
<Controller
control={control}
name={dottedName}
rules={rules}
render={({ field, fieldState }) => {
const displayValue = transform?.input ? transform.input(field.value) : field.value;
const help = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: undefined;
const childProps: Record<string, unknown> = {
[valueProp]: displayValue,
onChange: (...args: unknown[]) => {
const raw = normalizeAntdOnChange(args, valueProp);
const next = transform?.output ? transform.output(raw) : raw;
field.onChange(next);
onAfterChange?.(next);
},
onBlur: field.onBlur,
ref: field.ref,
};
return (
<Form.Item
label={label}
tooltip={tooltip}
extra={extra}
required={required}
validateStatus={fieldState.error ? 'error' : undefined}
help={help}
noStyle={noStyle}
className={className}
style={style}
>
{cloneElement(children as ReactElement<Record<string, unknown>>, childProps)}
</Form.Item>
);
}}
/>
);
}
export default FormField;
@@ -0,0 +1,5 @@
export { FormField, type FormFieldProps } from './FormField';
export { useZodForm } from './useZodForm';
export { rhfZodValidate } from './rhfZodValidate';
export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
export { toDotted, type FieldName } from './toDotted';
@@ -0,0 +1,10 @@
export type ValueProp = 'value' | 'checked';
export function normalizeAntdOnChange(args: unknown[], valueProp: ValueProp): unknown {
const first = args[0];
if (first !== null && typeof first === 'object' && 'target' in first) {
const target = (first as { target: { value?: unknown; checked?: unknown } }).target;
return valueProp === 'checked' ? target.checked : target.value;
}
return first;
}
@@ -0,0 +1,9 @@
import type { z } from 'zod';
export function rhfZodValidate<S extends z.ZodType>(schema: S) {
return (value: unknown): string | true => {
const result = schema.safeParse(value);
if (result.success) return true;
return result.error.issues[0]?.message ?? 'validation.invalid';
};
}
@@ -0,0 +1,5 @@
export type FieldName = string | ReadonlyArray<string | number>;
export function toDotted(name: FieldName): string {
return typeof name === 'string' ? name : name.join('.');
}
@@ -0,0 +1,18 @@
import { useForm } from 'react-hook-form';
import type { FieldValues, Resolver, UseFormProps, UseFormReturn } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { z } from 'zod';
export function useZodForm<TFieldValues extends FieldValues>(
schema: z.ZodType<TFieldValues>,
options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
): UseFormReturn<TFieldValues> {
const resolver = zodResolver(schema as z.ZodType<TFieldValues, TFieldValues>) as Resolver<TFieldValues>;
return useForm<TFieldValues>({
mode: 'onSubmit',
reValidateMode: 'onChange',
shouldUnregister: false,
...options,
resolver,
});
}
@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import InfinityIcon from './InfinityIcon';
const meta = {
title: 'UI/InfinityIcon',
component: InfinityIcon,
tags: ['autodocs'],
} satisfies Meta<typeof InfinityIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Large: Story = {
args: { width: 48, height: 34 },
};
export const InlineWithText: Story = {
render: () => (
<span style={{ fontSize: 16 }}>
Unlimited traffic <InfinityIcon />
</span>
),
};
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Input, Space } from 'antd';
import InputAddon from './InputAddon';
const meta = {
title: 'UI/InputAddon',
component: InputAddon,
tags: ['autodocs'],
} satisfies Meta<typeof InputAddon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Static: Story = {
args: { children: 'https://' },
};
export const Clickable: Story = {
args: { children: 'Copy', ariaLabel: 'Copy value', onClick: () => undefined },
};
export const BesideInput: Story = {
args: { children: 'https://' },
render: () => (
<Space.Compact>
<InputAddon>https://</InputAddon>
<Input defaultValue="panel.example.com" style={{ width: 220 }} />
</Space.Compact>
),
};
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber, Switch } from 'antd';
import SettingListItem from './SettingListItem';
const meta = {
title: 'UI/SettingListItem',
component: SettingListItem,
tags: ['autodocs'],
parameters: { layout: 'padded' },
} satisfies Meta<typeof SettingListItem>;
export default meta;
type Story = StoryObj<typeof meta>;
export const WithSwitch: Story = {
args: {
title: 'Enable subscription',
description: 'Expose an aggregated subscription URL for this client.',
control: <Switch defaultChecked />,
},
};
export const WithNumber: Story = {
args: {
title: 'Traffic limit',
description: 'Cap total traffic in gigabytes. Zero means unlimited.',
control: <InputNumber min={0} defaultValue={100} style={{ width: '100%' }} />,
},
};
export const CompactPadding: Story = {
args: {
paddings: 'small',
title: 'Auto renew',
description: 'Restart the quota window automatically on depletion.',
control: <Switch />,
},
};
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Switch } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { NotificationCard } from './NotificationCard';
const meta = {
title: 'UI/Notifications/NotificationCard',
component: NotificationCard,
tags: ['autodocs'],
parameters: { layout: 'padded' },
} satisfies Meta<typeof NotificationCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
icon: <BellOutlined />,
title: 'Telegram',
extra: <Switch defaultChecked />,
children: <span>Push a message to the configured chat when an event fires.</span>,
},
};
export const Disabled: Story = {
args: {
icon: <BellOutlined />,
title: 'Email',
extra: <Switch />,
children: <span>Email delivery is turned off for this channel.</span>,
},
};
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import Sparkline from './Sparkline';
const wave = Array.from({ length: 48 }, (_, i) => 45 + Math.round(28 * Math.sin(i / 4) + (i % 5) * 3));
const inverse = wave.map((v) => Math.max(0, 100 - v));
const meta = {
title: 'Viz/Sparkline',
component: Sparkline,
tags: ['autodocs'],
parameters: { layout: 'padded' },
} satisfies Meta<typeof Sparkline>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { data: wave, height: 80 },
};
export const AxesAndGrid: Story = {
args: { data: wave, height: 140, showAxes: true, showGrid: true, name1: 'CPU' },
};
export const Extrema: Story = {
args: { data: wave, height: 140, name1: 'CPU', extrema: { show: true } },
};
export const MultiSeriesTooltip: Story = {
args: {
data: wave,
data2: inverse,
name1: 'Upload',
name2: 'Download',
height: 140,
showTooltip: true,
showAxes: true,
},
};
@@ -0,0 +1,47 @@
import { useEffect, useRef, useState } from 'react';
import { Form } from 'antd';
import { FinalMaskForm } from '@/lib/xray/forms/transport';
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
interface FinalMaskFieldProps {
value?: FinalMaskStreamSettings;
onChange?: (next: FinalMaskStreamSettings) => void;
network: string;
protocol: string;
showAll?: boolean;
}
const EMPTY: FinalMaskStreamSettings = { tcp: [], udp: [] };
export default function FinalMaskField({ value, onChange, network, protocol, showAll }: FinalMaskFieldProps) {
const [form] = Form.useForm();
const [initial] = useState(() => value ?? EMPTY);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const lastEmitted = useRef(JSON.stringify(initial));
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
useEffect(() => {
if (finalmask === undefined) return;
const serialized = JSON.stringify(finalmask);
if (serialized === lastEmitted.current) return;
lastEmitted.current = serialized;
onChangeRef.current?.(finalmask);
}, [finalmask]);
return (
<Form
form={form}
component={false}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
initialValues={{ finalmask: initial }}
>
<FinalMaskForm name="finalmask" network={network} protocol={protocol} form={form} showAll={showAll} />
</Form>
);
}
@@ -0,0 +1,43 @@
import { useEffect, useRef, useState } from 'react';
import { Form } from 'antd';
import SniffingFields from '@/lib/xray/forms/SniffingFields';
import type { Sniffing } from '@/schemas/primitives/sniffing';
interface SniffingFieldProps {
value?: Sniffing;
onChange?: (next: Sniffing) => void;
enableLabel: string;
}
export default function SniffingField({ value, onChange, enableLabel }: SniffingFieldProps) {
const [form] = Form.useForm();
const [initial] = useState(() => value ?? ({} as Sniffing));
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const lastEmitted = useRef(JSON.stringify(initial));
const sniffing = Form.useWatch('sniffing', form) as Sniffing | undefined;
useEffect(() => {
if (sniffing === undefined) return;
const serialized = JSON.stringify(sniffing);
if (serialized === lastEmitted.current) return;
lastEmitted.current = serialized;
onChangeRef.current?.(sniffing);
}, [sniffing]);
return (
<Form
form={form}
component={false}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
initialValues={{ sniffing: initial }}
>
<SniffingFields name={['sniffing']} form={form} enableLabel={enableLabel} />
</Form>
);
}
@@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from 'react';
import { Form } from 'antd';
import { CustomSockoptList } from '@/components/form';
import type { CustomSockopt } from '@/schemas/protocols/stream/sockopt';
interface SockoptCustomFieldProps {
value?: CustomSockopt[];
onChange?: (next: CustomSockopt[]) => void;
}
export default function SockoptCustomField({ value, onChange }: SockoptCustomFieldProps) {
const [form] = Form.useForm();
const [initial] = useState(() => value ?? []);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const lastEmitted = useRef(JSON.stringify(initial));
const list = Form.useWatch('customSockopt', form) as CustomSockopt[] | undefined;
useEffect(() => {
if (list === undefined) return;
const serialized = JSON.stringify(list);
if (serialized === lastEmitted.current) return;
lastEmitted.current = serialized;
onChangeRef.current?.(list);
}, [list]);
return (
<Form form={form} component={false} initialValues={{ customSockopt: initial }}>
<CustomSockoptList name={['customSockopt']} />
</Form>
);
}
@@ -0,0 +1,3 @@
export { default as FinalMaskField } from './FinalMaskField';
export { default as SniffingField } from './SniffingField';
export { default as SockoptCustomField } from './SockoptCustomField';
@@ -1,6 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AutoComplete, Form, Modal, message } from 'antd'; import { AutoComplete, Form, Modal, message } from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
type GroupFormValues = { group: string };
const EMPTY: GroupFormValues = { group: '' };
interface BulkAddToGroupModalProps { interface BulkAddToGroupModalProps {
open: boolean; open: boolean;
@@ -19,15 +26,16 @@ export default function BulkAddToGroupModal({
}: BulkAddToGroupModalProps) { }: BulkAddToGroupModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const methods = useForm<GroupFormValues>({ defaultValues: EMPTY });
const group = useWatch({ control: methods.control, name: 'group' });
useEffect(() => { useEffect(() => {
if (open) setValue(''); if (open) methods.reset(EMPTY);
}, [open]); }, [open, methods]);
async function submit() { async function submit() {
const next = value.trim(); const next = (methods.getValues().group ?? '').trim();
if (!next) return; if (!next) return;
setSubmitting(true); setSubmitting(true);
try { try {
@@ -51,26 +59,28 @@ export default function BulkAddToGroupModal({
okText={t('add')} okText={t('add')}
cancelText={t('cancel')} cancelText={t('cancel')}
confirmLoading={submitting} confirmLoading={submitting}
okButtonProps={{ disabled: !value.trim() }} okButtonProps={{ disabled: !(group ?? '').trim() }}
onCancel={() => onOpenChange(false)} onCancel={() => onOpenChange(false)}
onOk={submit} onOk={submit}
destroyOnHidden destroyOnHidden
> >
<Form layout="vertical"> <FormProvider {...methods}>
<Form.Item <Form layout="vertical">
label={t('pages.clients.group')} <FormField
tooltip={t('pages.clients.addToGroupTooltip')} name="group"
> label={t('pages.clients.group')}
<AutoComplete tooltip={t('pages.clients.addToGroupTooltip')}
value={value} transform={{ output: (v) => v ?? '' }}
placeholder={t('pages.clients.groupName')} >
options={groups.map((g) => ({ value: g }))} <AutoComplete
onChange={(v) => setValue(v ?? '')} placeholder={t('pages.clients.groupName')}
allowClear options={groups.map((g) => ({ value: g }))}
autoFocus allowClear
/> autoFocus
</Form.Item> />
</Form> </FormField>
</Form>
</FormProvider>
</Modal> </Modal>
</> </>
); );
+196 -195
View File
@@ -4,11 +4,13 @@ import { AutoComplete, Button, Form, Input, InputNumber, Modal, Select, Space, S
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { RandomUtil, SizeFormatter } from '@/utils'; import { RandomUtil, SizeFormatter } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label'; import { formatInboundLabel } from '@/lib/inbounds/label';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives'; import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form'; import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { useClients, type InboundOption } from '@/hooks/useClients'; import { useClients, type InboundOption } from '@/hooks/useClients';
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery'; import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client'; import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
@@ -19,6 +21,24 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', '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 { interface ClientBulkAddModalProps {
open: boolean; open: boolean;
inbounds: InboundOption[]; inbounds: InboundOption[];
@@ -27,28 +47,6 @@ interface ClientBulkAddModalProps {
onSaved?: () => void; 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({ export default function ClientBulkAddModal({
open, open,
inbounds, inbounds,
@@ -60,7 +58,14 @@ export default function ClientBulkAddModal({
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const { bulkCreate } = useClients(); const { bulkCreate } = useClients();
const [form, setForm] = useState<FormState>(emptyForm); const methods = useForm<ClientBulkAddFormValues>({ 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 [delayedStart, setDelayedStart] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const fail2ban = useFail2banStatusQuery(); const fail2ban = useFail2banStatusQuery();
@@ -70,14 +75,10 @@ export default function ClientBulkAddModal({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setForm(emptyForm()); methods.reset(EMPTY);
setDelayedStart(false); setDelayedStart(false);
}, [open]); }, [open, methods]);
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
}
const flowCapableIds = useMemo(() => { const flowCapableIds = useMemo(() => {
const ids = new Set<number>(); const ids = new Set<number>();
@@ -88,25 +89,25 @@ export default function ClientBulkAddModal({
}, [inbounds]); }, [inbounds]);
const showFlow = useMemo( const showFlow = useMemo(
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)), () => (inboundIds || []).some((id) => flowCapableIds.has(id)),
[form.inboundIds, flowCapableIds], [inboundIds, flowCapableIds],
); );
const ss2022Method = useMemo(() => { const ss2022Method = useMemo(() => {
for (const id of form.inboundIds || []) { for (const id of inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id); const ib = (inbounds || []).find((row) => row.id === id);
const method = ib?.ssMethod; const method = ib?.ssMethod;
if (method && method.substring(0, 4) === '2022') return method; if (method && method.substring(0, 4) === '2022') return method;
} }
return ''; return '';
}, [form.inboundIds, inbounds]); }, [inboundIds, inbounds]);
useEffect(() => { useEffect(() => {
if (!showFlow && form.flow) { if (!showFlow && flow) {
update('flow', ''); methods.setValue('flow', '');
} }
}, [showFlow, form.flow]); }, [showFlow, flow, methods]);
const inboundOptions = useMemo( const inboundOptions = useMemo(
() => (inbounds || []) () => (inbounds || [])
@@ -119,27 +120,27 @@ export default function ClientBulkAddModal({
); );
const expiryDate = useMemo<Dayjs | null>( const expiryDate = useMemo<Dayjs | null>(
() => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null), () => (expiryTime > 0 ? dayjs(expiryTime) : null),
[form.expiryTime], [expiryTime],
); );
const delayedExpireDays = form.expiryTime < 0 ? form.expiryTime / -86400000 : 0; const delayedExpireDays = expiryTime < 0 ? expiryTime / -86400000 : 0;
function buildEmails(): string[] { function buildEmails(values: ClientBulkAddFormValues): string[] {
const method = form.emailMethod; const method = values.emailMethod;
const out: string[] = []; const out: string[] = [];
let start: number; let start: number;
let end: number; let end: number;
if (method > 1) { if (method > 1) {
start = form.firstNum; start = values.firstNum;
end = form.lastNum + 1; end = values.lastNum + 1;
} else { } else {
start = 0; 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 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++) { for (let i = start; i < end; i++) {
let email = ''; let email = '';
if (method !== 4) email = RandomUtil.randomLowerAndNum(10); if (method !== 4) email = RandomUtil.randomLowerAndNum(10);
@@ -150,12 +151,13 @@ export default function ClientBulkAddModal({
} }
async function submit() { async function submit() {
const validated = ClientBulkAddFormSchema.safeParse(form); const current = methods.getValues();
const validated = ClientBulkAddFormSchema.safeParse(current);
if (!validated.success) { if (!validated.success) {
messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong')); messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
return; return;
} }
const emails = buildEmails(); const emails = buildEmails(current);
if (emails.length === 0) return; if (emails.length === 0) return;
setSaving(true); setSaving(true);
@@ -163,22 +165,22 @@ export default function ClientBulkAddModal({
const payloads = emails.map((email) => ({ const payloads = emails.map((email) => ({
client: { client: {
email, email,
subId: form.subId || RandomUtil.randomLowerAndNum(16), subId: current.subId || RandomUtil.randomLowerAndNum(16),
id: RandomUtil.randomUUID(), id: RandomUtil.randomUUID(),
password: ss2022Method password: ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method) ? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16), : RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16), auth: RandomUtil.randomLowerAndNum(16),
flow: showFlow ? (form.flow || '') : '', flow: showFlow ? (current.flow || '') : '',
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB), totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
expiryTime: form.expiryTime, expiryTime: current.expiryTime,
reset: Number(form.reset) || 0, reset: Number(current.reset) || 0,
limitIp: Number(form.limitIp) || 0, limitIp: Number(current.limitIp) || 0,
group: form.group, group: current.group,
comment: form.comment, comment: current.comment,
enable: true, enable: true,
}, },
inboundIds: form.inboundIds, inboundIds: current.inboundIds,
})); }));
const msg = await bulkCreate(payloads); const msg = await bulkCreate(payloads);
const ok = msg?.obj?.created ?? 0; const ok = msg?.obj?.created ?? 0;
@@ -213,157 +215,156 @@ export default function ClientBulkAddModal({
onOk={submit} onOk={submit}
onCancel={() => onOpenChange(false)} onCancel={() => onOpenChange(false)}
> >
<Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}> <FormProvider {...methods}>
<Form.Item label={t('pages.clients.attachedInbounds')} required> <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
<SelectAllClearButtons <Form.Item label={t('pages.clients.attachedInbounds')} required>
options={inboundOptions} <SelectAllClearButtons
value={form.inboundIds} options={inboundOptions}
onChange={(v) => update('inboundIds', v)} value={inboundIds}
/> onChange={(v) => methods.setValue('inboundIds', v)}
<Select
mode="multiple"
value={form.inboundIds}
onChange={(v) => update('inboundIds', v)}
options={inboundOptions}
placeholder={t('pages.clients.selectInbound')}
showSearch={{
filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
}}
/>
</Form.Item>
<Form.Item label={t('pages.clients.method')}>
<Select
value={form.emailMethod}
onChange={(v) => 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.Item>
{form.emailMethod > 1 && (
<>
<Form.Item label={t('pages.clients.first')}>
<InputNumber value={form.firstNum} min={1} onChange={(v) => update('firstNum', Number(v) || 1)} />
</Form.Item>
<Form.Item label={t('pages.clients.last')}>
<InputNumber value={form.lastNum} min={form.firstNum} onChange={(v) => update('lastNum', Number(v) || 1)} />
</Form.Item>
</>
)}
{form.emailMethod > 0 && (
<Form.Item label={t('pages.clients.prefix')}>
<Input value={form.emailPrefix} onChange={(e) => update('emailPrefix', e.target.value)} />
</Form.Item>
)}
{form.emailMethod > 2 && (
<Form.Item label={t('pages.clients.postfix')}>
<Input value={form.emailPostfix} onChange={(e) => update('emailPostfix', e.target.value)} />
</Form.Item>
)}
{form.emailMethod < 2 && (
<Form.Item label={t('pages.clients.clientCount')}>
<InputNumber value={form.quantity} min={1} max={1000} onChange={(v) => update('quantity', Number(v) || 1)} />
</Form.Item>
)}
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input
value={form.subId}
onChange={(e) => update('subId', e.target.value)}
style={{ flex: 1 }}
/> />
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
/>
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
<AutoComplete
value={form.group}
placeholder={t('pages.clients.groupPlaceholder')}
options={groups.map((g) => ({ value: g }))}
onChange={(v) => update('group', v ?? '')}
allowClear
/>
</Form.Item>
<Form.Item label={t('comment')}>
<Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
</Form.Item>
{showFlow && (
<Form.Item label={t('pages.clients.flow')}>
<Select <Select
value={form.flow} mode="multiple"
onChange={(v) => update('flow', v)} value={inboundIds}
style={{ width: 220 }} 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()),
}}
/>
</Form.Item>
<FormField name="emailMethod" label={t('pages.clients.method')}>
<Select
options={[ options={[
{ value: '', label: t('none') }, { value: 0, label: 'Random' },
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })), { value: 1, label: 'Random + Prefix' },
{ value: 2, label: 'Random + Prefix + Num' },
{ value: 3, label: 'Random + Prefix + Num + Postfix' },
{ value: 4, label: 'Prefix + Num + Postfix' },
]} ]}
/> />
</FormField>
{emailMethod > 1 && (
<>
<FormField name="firstNum" label={t('pages.clients.first')} transform={{ output: (v) => Number(v) || 1 }}>
<InputNumber min={1} />
</FormField>
<FormField name="lastNum" label={t('pages.clients.last')} transform={{ output: (v) => Number(v) || 1 }}>
<InputNumber min={firstNum} />
</FormField>
</>
)}
{emailMethod > 0 && (
<FormField name="emailPrefix" label={t('pages.clients.prefix')}>
<Input />
</FormField>
)}
{emailMethod > 2 && (
<FormField name="emailPostfix" label={t('pages.clients.postfix')}>
<Input />
</FormField>
)}
{emailMethod < 2 && (
<FormField name="quantity" label={t('pages.clients.clientCount')} transform={{ output: (v) => Number(v) || 1 }}>
<InputNumber min={1} max={1000} />
</FormField>
)}
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input
value={subId}
onChange={(e) => methods.setValue('subId', e.target.value)}
style={{ flex: 1 }}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))}
/>
</Space.Compact>
</Form.Item> </Form.Item>
)}
<Form.Item label={t('pages.clients.limitIp')}> <FormField
<Tooltip title={limitIpNotice || undefined}> name="group"
<span style={{ display: 'inline-flex' }}> label={t('pages.clients.group')}
<InputNumber value={form.limitIp} min={0} disabled={limitIpDisabled} tooltip={t('pages.clients.groupDesc')}
style={limitIpDisabled ? { pointerEvents: 'none' } : undefined} transform={{ output: (v) => v ?? '' }}
onChange={(v) => update('limitIp', Number(v) || 0)} /> >
</span> <AutoComplete
</Tooltip> placeholder={t('pages.clients.groupPlaceholder')}
</Form.Item> options={groups.map((g) => ({ value: g }))}
allowClear
/>
</FormField>
<Form.Item label={t('pages.clients.totalGB')}> <FormField name="comment" label={t('comment')}>
<InputNumber value={form.totalGB} min={0} step={1} onChange={(v) => update('totalGB', Number(v) || 0)} /> <Input />
</Form.Item> </FormField>
<Form.Item label={t('pages.clients.delayedStart')}> {showFlow && (
<Switch <FormField name="flow" label={t('pages.clients.flow')}>
checked={delayedStart} <Select
onClick={() => { setDelayedStart(!delayedStart); update('expiryTime', 0); }} style={{ width: 220 }}
/> options={[
</Form.Item> { value: '', label: t('none') },
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
]}
/>
</FormField>
)}
{delayedStart ? ( <Form.Item label={t('pages.clients.limitIp')}>
<Form.Item label={t('pages.clients.expireDays')}> <Tooltip title={limitIpNotice || undefined}>
<InputNumber <span style={{ display: 'inline-flex' }}>
value={delayedExpireDays} <InputNumber value={limitIp} min={0} disabled={limitIpDisabled}
min={0} style={limitIpDisabled ? { pointerEvents: 'none' } : undefined}
onChange={(v) => update('expiryTime', -86400000 * (Number(v) || 0))} onChange={(v) => methods.setValue('limitIp', Number(v) || 0)} />
</span>
</Tooltip>
</Form.Item>
<FormField name="totalGB" label={t('pages.clients.totalGB')} transform={{ output: (v) => Number(v) || 0 }}>
<InputNumber min={0} step={1} />
</FormField>
<Form.Item label={t('pages.clients.delayedStart')}>
<Switch
checked={delayedStart}
onClick={() => { setDelayedStart(!delayedStart); methods.setValue('expiryTime', 0); }}
/> />
</Form.Item> </Form.Item>
) : (
<Form.Item label={t('pages.inbounds.expireDate')}>
<DateTimePicker
value={expiryDate}
onChange={(next) => update('expiryTime', next ? next.valueOf() : 0)}
/>
</Form.Item>
)}
<Form.Item {delayedStart ? (
label={t('pages.clients.renew')} <Form.Item label={t('pages.clients.expireDays')}>
tooltip={t('pages.clients.renewDesc')} <InputNumber
> value={delayedExpireDays}
<InputNumber min={0}
value={form.reset} onChange={(v) => methods.setValue('expiryTime', -86400000 * (Number(v) || 0))}
min={0} />
onChange={(v) => update('reset', Number(v) || 0)} </Form.Item>
/> ) : (
</Form.Item> <Form.Item label={t('pages.inbounds.expireDate')}>
</Form> <DateTimePicker
value={expiryDate}
onChange={(next) => methods.setValue('expiryTime', next ? next.valueOf() : 0)}
/>
</Form.Item>
)}
<FormField
name="reset"
label={t('pages.clients.renew')}
tooltip={t('pages.clients.renewDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} />
</FormField>
</Form>
</FormProvider>
</Modal> </Modal>
</> </>
); );
@@ -1,14 +1,18 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Modal, Select, message } from 'antd'; 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 { TLS_FLOW_CONTROL } from '@/schemas/primitives/flow';
import { FormField } from '@/components/form/rhf';
const GB = 1024 * 1024 * 1024; const GB = 1024 * 1024 * 1024;
const FLOW_CLEAR = 'none'; const FLOW_CLEAR = 'none';
const EMPTY: ClientBulkAdjustFormValues = { addDays: 0, addGB: 0, flow: '' };
interface ClientBulkAdjustModalProps { interface ClientBulkAdjustModalProps {
open: boolean; open: boolean;
count: number; count: number;
@@ -19,24 +23,19 @@ interface ClientBulkAdjustModalProps {
export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) { export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [addDays, setAddDays] = useState<number>(0);
const [addGB, setAddGB] = useState<number>(0);
const [flow, setFlow] = useState<string>('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const methods = useForm<ClientBulkAdjustFormValues>({ defaultValues: EMPTY });
useEffect(() => { useEffect(() => {
if (open) { if (open) methods.reset(EMPTY);
setAddDays(0); }, [open, methods]);
setAddGB(0);
setFlow('');
}
}, [open]);
async function handleOk() { async function handleOk() {
const values = methods.getValues();
const validated = ClientBulkAdjustFormSchema.safeParse({ const validated = ClientBulkAdjustFormSchema.safeParse({
addDays: Math.trunc(Number(addDays) || 0), addDays: Math.trunc(Number(values.addDays) || 0),
addGB: Number(addGB) || 0, addGB: Number(values.addGB) || 0,
flow, flow: values.flow,
}); });
if (!validated.success) { if (!validated.success) {
messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong')); messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
@@ -83,37 +82,26 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
title={t('pages.clients.bulkAdjustHint')} title={t('pages.clients.bulkAdjustHint')}
/> />
<Form layout="vertical"> <FormProvider {...methods}>
<Form.Item label={t('pages.clients.addDays')}> <Form layout="vertical">
<InputNumber <FormField name="addDays" label={t('pages.clients.addDays')}>
value={addDays} <InputNumber style={{ width: '100%' }} step={1} precision={0} />
onChange={(v) => setAddDays(Number(v) || 0)} </FormField>
style={{ width: '100%' }} <FormField name="addGB" label={t('pages.clients.addTrafficGB')}>
step={1} <InputNumber style={{ width: '100%' }} step={1} />
precision={0} </FormField>
/> <FormField name="flow" label={t('pages.clients.bulkFlow')}>
</Form.Item> <Select
<Form.Item label={t('pages.clients.addTrafficGB')}> style={{ width: '100%' }}
<InputNumber options={[
value={addGB} { value: '', label: t('pages.clients.bulkFlowNoChange') },
onChange={(v) => setAddGB(Number(v) || 0)} { value: FLOW_CLEAR, label: t('pages.clients.bulkFlowDisable') },
style={{ width: '100%' }} ...Object.values(TLS_FLOW_CONTROL).map((k) => ({ value: k, label: k })),
step={1} ]}
/> />
</Form.Item> </FormField>
<Form.Item label={t('pages.clients.bulkFlow')}> </Form>
<Select </FormProvider>
value={flow}
onChange={setFlow}
style={{ width: '100%' }}
options={[
{ value: '', label: t('pages.clients.bulkFlowNoChange') },
{ value: FLOW_CLEAR, label: t('pages.clients.bulkFlowDisable') },
...Object.values(TLS_FLOW_CONTROL).map((k) => ({ value: k, label: k })),
]}
/>
</Form.Item>
</Form>
</Modal> </Modal>
</> </>
); );
File diff suppressed because it is too large Load Diff
+226 -209
View File
@@ -11,18 +11,22 @@ import {
DeploymentUnitOutlined, DeploymentUnitOutlined,
RocketOutlined, RocketOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import type { HostRecord } from '@/api/queries/useHostsQuery'; import type { HostRecord } from '@/api/queries/useHostsQuery';
import type { HostFormValues } from '@/schemas/api/host'; import { HostFormSchema, type HostFormValues } from '@/schemas/api/host';
import type { InboundOption } from '@/schemas/client'; import type { InboundOption } from '@/schemas/client';
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives'; import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useNodesQuery } from '@/api/queries/useNodesQuery'; import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel'; import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms'; import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
// inboundId is optional in the form so a new host starts unselected (the Select /*
// shows its placeholder instead of 0); the required rule enforces it on submit. * inboundId is optional in the form so a new host starts unselected (the Select
* shows its placeholder instead of 0); the required rule enforces it on submit.
*/
type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number }; type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number };
interface HostFormModalProps { interface HostFormModalProps {
@@ -74,19 +78,21 @@ function defaultsFor(host: HostRecord | null): FormShape {
export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) { export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [form] = Form.useForm<FormShape>(); const methods = useForm<FormShape>({ 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 * Drive conditional field visibility off the selected security, like the
// field; reality shows only the reality-relevant subset (its keys are * legacy externalProxy form: same/none inherit fully and hide every TLS/cert
// inherited from the inbound); tls shows the full TLS override set. * field; reality shows only the reality-relevant subset (its keys are
const security = (Form.useWatch('security', form) ?? 'same') as string; * 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 showTls = security === 'tls' || security === 'reality';
const showTlsExtras = security === 'tls'; const showTlsExtras = security === 'tls';
useEffect(() => { useEffect(() => {
if (open) form.setFieldsValue(defaultsFor(host)); if (open) methods.reset(defaultsFor(host));
}, [open, host, form]); }, [open, host, methods]);
const { nodes } = useNodesQuery(); 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 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 fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
const onOk = async () => { const onFinish = async (values: FormShape) => {
let values: FormShape;
try {
values = await form.validateFields();
} catch {
return;
}
const { enable, ...rest } = values; const { enable, ...rest } = values;
const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable }; const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable };
const res = await save(payload); const res = await save(payload);
@@ -130,7 +130,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
<Modal <Modal
open={open} open={open}
title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')} title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
onOk={onOk} onOk={methods.handleSubmit(onFinish)}
onCancel={() => onOpenChange(false)} onCancel={() => onOpenChange(false)}
okText={t('save')} okText={t('save')}
cancelText={t('cancel')} cancelText={t('cancel')}
@@ -138,198 +138,215 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
width={isMobile ? '95vw' : 760} width={isMobile ? '95vw' : 760}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }} styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
> >
<Form <FormProvider {...methods}>
form={form} <Form
colon={false} colon={false}
labelCol={{ sm: { span: 8 } }} labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }} wrapperCol={{ sm: { span: 14 } }}
labelWrap labelWrap
initialValues={defaultsFor(host)} >
preserve={false} <Tabs
> defaultActiveKey="basic"
<Tabs items={[
defaultActiveKey="basic" {
items={[ key: 'basic',
{ forceRender: true,
key: 'basic', label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
forceRender: true, children: (
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile), <>
children: ( <FormField name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.remark) }}>
<> <Input maxLength={256} />
<Form.Item name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={[{ required: true, max: 256 }]}> </FormField>
<Input maxLength={256} /> <FormField name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
</Form.Item> <Input maxLength={64} />
<Form.Item name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}> </FormField>
<Input maxLength={64} /> <FormField name="inboundId" label={t('pages.hosts.fields.inbound')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.inboundId) }}>
</Form.Item> <Select
<Form.Item name="inboundId" label={t('pages.hosts.fields.inbound')} rules={[{ required: true }]}> options={inboundSelectOptions}
<Select showSearch
options={inboundSelectOptions} optionFilterProp="label"
showSearch disabled={mode === 'edit'}
optionFilterProp="label" placeholder={t('pages.hosts.selectInbound')}
disabled={mode === 'edit'} />
placeholder={t('pages.hosts.selectInbound')} </FormField>
/> <FormField name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
</Form.Item> <Input placeholder="cdn.example.com" />
<Form.Item name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}> </FormField>
<Input placeholder="cdn.example.com" /> <FormField name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
</Form.Item> <InputNumber min={0} max={65535} />
<Form.Item name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}> </FormField>
<InputNumber min={0} max={65535} /> <FormField name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
</Form.Item> <Select mode="tags" allowClear tokenSeparators={[',']} />
<Form.Item name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}> </FormField>
<Select mode="tags" allowClear tokenSeparators={[',']} /> <FormField name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
</Form.Item> <Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
<Form.Item name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}> </FormField>
<Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" /> <FormField name="enable" label={t('pages.hosts.fields.enable')} valueProp="checked">
</Form.Item> <Switch />
<Form.Item name="enable" label={t('pages.hosts.fields.enable')} valuePropName="checked"> </FormField>
<Switch /> </>
</Form.Item> ),
</> },
), {
}, key: 'security',
{ forceRender: true,
key: 'security', label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
forceRender: true, children: (
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile), <>
children: ( <FormField name="security" label={t('pages.hosts.fields.security')}>
<> <Select
<Form.Item name="security" label={t('pages.hosts.fields.security')}> options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
<Select />
options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))} </FormField>
/> {showTls && (
</Form.Item> <>
{showTls && ( <FormField name="sni" label={t('pages.hosts.fields.sni')}>
<> <Input />
<Form.Item name="sni" label={t('pages.hosts.fields.sni')}> </FormField>
<Input /> <FormField name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valueProp="checked">
</Form.Item> <Switch />
<Form.Item name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valuePropName="checked"> </FormField>
<Switch /> <FormField name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valueProp="checked">
</Form.Item> <Switch />
<Form.Item name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valuePropName="checked"> </FormField>
<Switch /> <FormField name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
</Form.Item> <Select allowClear options={fpOptions} />
<Form.Item name="fingerprint" label={t('pages.hosts.fields.fingerprint')}> </FormField>
<Select allowClear options={fpOptions} /> </>
</Form.Item> )}
</> {showTlsExtras && (
)} <>
{showTlsExtras && ( <FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
<> <Select mode="multiple" allowClear options={alpnOptions} />
<Form.Item name="alpn" label={t('pages.hosts.fields.alpn')}> </FormField>
<Select mode="multiple" allowClear options={alpnOptions} /> <FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
</Form.Item> <Select mode="tags" allowClear tokenSeparators={[',']} />
<Form.Item name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}> </FormField>
<Select mode="tags" allowClear tokenSeparators={[',']} /> <FormField name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}>
</Form.Item> <Input placeholder="example.com" />
<Form.Item name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}> </FormField>
<Input placeholder="example.com" /> <FormField name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valueProp="checked">
</Form.Item> <Switch />
<Form.Item name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valuePropName="checked"> </FormField>
<Switch /> <FormField name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
</Form.Item> <Input.TextArea rows={2} />
<Form.Item name="echConfigList" label={t('pages.hosts.fields.echConfigList')}> </FormField>
<Input.TextArea rows={2} /> </>
</Form.Item> )}
</> </>
)} ),
</> },
), {
}, key: 'advanced',
{ forceRender: true,
key: 'advanced', label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
forceRender: true, children: (
label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile), <Tabs
children: ( size="small"
<Tabs defaultActiveKey="adv-general"
size="small" items={[
defaultActiveKey="adv-general" {
items={[ key: 'adv-general',
{ forceRender: true,
key: 'adv-general', label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
forceRender: true, children: (
label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile), <>
children: ( <FormField name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
<> <Input />
<Form.Item name="hostHeader" label={t('pages.hosts.fields.hostHeader')}> </FormField>
<Input /> <FormField name="path" label={t('pages.hosts.fields.path')}>
</Form.Item> <Input />
<Form.Item name="path" label={t('pages.hosts.fields.path')}> </FormField>
<Input /> <FormField name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
</Form.Item> <Input placeholder="443" />
<Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}> </FormField>
<Input placeholder="443" /> <FormField name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
</Form.Item> <Select
<Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}> mode="multiple"
<Select allowClear
mode="multiple" options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
allowClear />
options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))} </FormField>
</>
),
},
{
key: 'adv-mux',
forceRender: true,
label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
children: (
<Form.Item noStyle>
<Controller
control={methods.control}
name="muxParams"
render={({ field }) => (
<HostMuxForm value={field.value} onChange={field.onChange} />
)}
/> />
</Form.Item> </Form.Item>
</> ),
), },
}, {
{ key: 'adv-sockopt',
key: 'adv-mux', forceRender: true,
forceRender: true, label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile), children: (
children: ( <Form.Item noStyle>
<Form.Item name="muxParams" noStyle> <Controller
<HostMuxForm /> control={methods.control}
</Form.Item> name="sockoptParams"
), render={({ field }) => (
}, <HostSockoptForm value={field.value} onChange={field.onChange} />
{ )}
key: 'adv-sockopt', />
forceRender: true, </Form.Item>
label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile), ),
children: ( },
<Form.Item name="sockoptParams" noStyle> {
<HostSockoptForm /> key: 'adv-finalmask',
</Form.Item> forceRender: true,
), label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
}, children: (
{ <Form.Item noStyle>
key: 'adv-finalmask', <Controller
forceRender: true, control={methods.control}
label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile), name="finalMask"
children: ( render={({ field }) => (
<Form.Item name="finalMask" noStyle> <HostFinalMaskForm value={field.value} onChange={field.onChange} />
<HostFinalMaskForm /> )}
</Form.Item> />
), </Form.Item>
}, ),
]} },
/> ]}
), />
}, ),
{ },
key: 'clash', {
forceRender: true, key: 'clash',
label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile), forceRender: true,
children: ( label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
<> children: (
<Form.Item name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}> <>
<Select <FormField name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
allowClear <Select
options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))} allowClear
/> options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
</Form.Item> />
<Form.Item name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valuePropName="checked"> </FormField>
<Switch /> <FormField name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valueProp="checked">
</Form.Item> <Switch />
<Form.Item name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valuePropName="checked"> </FormField>
<Switch /> <FormField name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valueProp="checked">
</Form.Item> <Switch />
</> </FormField>
), </>
}, ),
]} },
/> ]}
</Form> />
</Form>
</FormProvider>
</Modal> </Modal>
); );
} }
@@ -3,10 +3,12 @@ import { MuxForm } from '@/pages/xray/outbounds/transport';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm'; import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers'; 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 * Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
// the sub-JSON editor; the host stores '' (= inherit the inbound/global mux) * settings editor). Stored in the host's muxParams JSON string. Defaults match
// when the toggle is off, an explicit mux object when on. * 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' }; const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' };
export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) { 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']} path={['mux']}
defaultSubtree={DEFAULT_MUX} defaultSubtree={DEFAULT_MUX}
serialize={(mux) => ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')} serialize={(mux) => ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')}
// protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate; /* protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
// a host's mux override is protocol-agnostic and should always be editable. a host's mux override is protocol-agnostic and should always be editable. */
render={(form) => <MuxForm form={form} protocol="vmess" network="tcp" />} render={() => <MuxForm protocol="vmess" network="tcp" />}
/> />
); );
} }
@@ -4,16 +4,18 @@ import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm'; import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers'; 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 * Sockopt override editor — reuses the outbound SockoptForm (which carries its
// sockoptParams JSON string. * 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: * A host is the client/dialer side, so the inbound-only sockopt keys are dropped
// only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor * from the output. Verified against xray-core transport/internet/sockopt_*.go:
// are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied * only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
// on the outbound/dialer socket, so they stay. The outbound form no longer shows * are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
// the inbound-only keys, but its default object still seeds them, so strip here. * 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']; const INBOUND_ONLY_SOCKOPT = ['acceptProxyProtocol', 'V6Only', 'trustedXForwardedFor'];
function serializeClientSockopt(sockopt: unknown): string { 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 }) { 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 * Populate the dialerProxy dropdown with the panel's outbound tags (a host can
// through a single outbound, so balancers (routing targets) are excluded — only * chain through one of the subscription's outbounds by tag). dialerProxy chains
// the outbound group is used; blackhole is dropped too (chaining to it just * through a single outbound, so balancers (routing targets) are excluded — only
// drops the traffic). * the outbound group is used; blackhole is dropped too (chaining to it just
* drops the traffic).
*/
const { data: tagGroups } = useOutboundTagGroups({ excludeBlackhole: true }); const { data: tagGroups } = useOutboundTagGroups({ excludeBlackhole: true });
const outboundTags = tagGroups?.outbounds ?? []; const outboundTags = tagGroups?.outbounds ?? [];
return ( return (
@@ -37,7 +41,7 @@ export default function HostSockoptForm({ value, onChange }: { value?: string; o
onChange={onChange} onChange={onChange}
path={['streamSettings', 'sockopt']} path={['streamSettings', 'sockopt']}
serialize={serializeClientSockopt} serialize={serializeClientSockopt}
render={(form) => <SockoptForm form={form} outboundTags={outboundTags} />} render={() => <SockoptForm outboundTags={outboundTags} />}
/> />
); );
} }
@@ -1,29 +1,32 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Form, type FormInstance } from 'antd'; import { Form } from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form'; import type { FieldValues } from 'react-hook-form';
import { nestAtPath, parseJsonObject, serializeOverride } from './helpers'; import { nestAtPath, parseJsonObject, serializeOverride } from './helpers';
interface OutboundSubtreeJsonFormProps { interface OutboundSubtreeJsonFormProps {
value?: string; value?: string;
onChange?: (next: string) => void; 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)[]; path: (string | number)[];
// Renders the reused outbound form given this wrapper's own form instance. /* Renders the reused outbound form, which binds to this wrapper's RHF context. */
render: (form: FormInstance<OutboundFormValues>) => ReactNode; render: () => ReactNode;
// Seeds the form when the stored value is empty, so toggling a section on /* Seeds the form when the stored value is empty, so toggling a section on
// pre-fills sensible defaults instead of blanks (used by Mux). pre-fills sensible defaults instead of blanks (used by Mux). */
defaultSubtree?: Record<string, unknown>; defaultSubtree?: Record<string, unknown>;
// Turns the edited subtree into the stored JSON string (default: prune empties). /* Turns the edited subtree into the stored JSON string (default: prune empties).
// Mux overrides this to store '' (= inherit) when its enable flag is off. Mux overrides this to store '' (= inherit) when its enable flag is off. */
serialize?: (subtree: unknown) => string; 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 * Hosts the reused outbound transport forms (which bind to fixed RHF paths)
// from the JSON string, watch the edited subtree, and report a JSON string back * inside an isolated RHF form, mirroring the sub-JSON adapters: seed the form
// to the parent host form. component={false} avoids a nested <form> DOM node. * 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 <form> DOM node); data binding runs through the RHF provider.
*/
export default function OutboundSubtreeJsonForm({ export default function OutboundSubtreeJsonForm({
value = '', value = '',
onChange, onChange,
@@ -32,37 +35,38 @@ export default function OutboundSubtreeJsonForm({
defaultSubtree, defaultSubtree,
serialize = serializeOverride, serialize = serializeOverride,
}: OutboundSubtreeJsonFormProps) { }: OutboundSubtreeJsonFormProps) {
const [form] = Form.useForm();
const [initial] = useState<Record<string, unknown>>(() => { const [initial] = useState<Record<string, unknown>>(() => {
const parsed = parseJsonObject(value); const parsed = parseJsonObject(value);
return Object.keys(parsed).length ? parsed : (defaultSubtree ?? {}); return Object.keys(parsed).length ? parsed : (defaultSubtree ?? {});
}); });
const [defaultValues] = useState<FieldValues>(() => {
const hasInitial = Object.keys(initial).length > 0;
return nestAtPath(path, hasInitial ? initial : undefined) as FieldValues;
});
const methods = useForm<FieldValues>({ defaultValues });
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; onChangeRef.current = onChange;
const subtree = Form.useWatch(path, form); const subtree = useWatch({ control: methods.control, name: path.join('.') });
useEffect(() => { useEffect(() => {
const next = serialize(subtree); const next = serialize(subtree);
if (next !== value) onChangeRef.current?.(next); if (next !== value) onChangeRef.current?.(next);
// serialize is logically stable; re-run only when the edited subtree changes. /* serialize is logically stable; re-run only when the edited subtree changes. */
// eslint-disable-next-line react-hooks/exhaustive-deps /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [subtree, value]); }, [subtree, value]);
const hasInitial = Object.keys(initial).length > 0;
const initialValues = nestAtPath(path, hasInitial ? initial : undefined);
return ( return (
<Form <FormProvider {...methods}>
form={form} <Form
component={false} component={false}
colon={false} colon={false}
labelCol={{ sm: { span: 8 } }} labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }} wrapperCol={{ sm: { span: 14 } }}
labelWrap labelWrap
initialValues={initialValues} >
> {render()}
{render(form as unknown as FormInstance<OutboundFormValues>)} </Form>
</Form> </FormProvider>
); );
} }
@@ -15,6 +15,7 @@ import {
Tooltip, Tooltip,
message, message,
} from 'antd'; } from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils'; import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
import type { RealityScanResult } from '@/generated/types'; import type { RealityScanResult } from '@/generated/types';
@@ -36,7 +37,7 @@ import {
InboundFormSchema, InboundFormSchema,
type InboundFormValues, type InboundFormValues,
} from '@/schemas/forms/inbound-form'; } from '@/schemas/forms/inbound-form';
import { antdRule } from '@/utils/zodForm'; import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { Protocols } from '@/schemas/primitives'; import { Protocols } from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria'; 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 { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade';
import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp'; import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
import { DateTimePicker } from '@/components/form'; import { DateTimePicker } from '@/components/form';
import { FinalMaskForm } from '@/lib/xray/forms/transport'; import { FinalMaskField } from '@/lib/xray/forms/fields';
import './InboundFormModal.css'; import './InboundFormModal.css';
import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors'; import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
@@ -85,7 +86,7 @@ import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; 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) => ( const labelWithHint = (label: string, hint: string) => (
<span> <span>
{label} {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<string, unknown> {
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({ export default function InboundFormModal({
open, open,
onClose, onClose,
@@ -174,7 +194,10 @@ export default function InboundFormModal({
}: InboundFormModalProps) { }: InboundFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [form] = Form.useForm<InboundFormValues>(); const methods = useForm<InboundFormValues>({ 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 [saving, setSaving] = useState(false);
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null); const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
@@ -191,41 +214,47 @@ export default function InboundFormModal({
} = useInboundFallbacks(dbInbound, dbInbounds); } = useInboundFallbacks(dbInbound, dbInbounds);
const selectableNodes = (availableNodes || []).filter((n) => n.enable); 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); 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 * The `node` share-address strategy only means something when the inbound can
// always empty. Offer it only then; `listen`/`custom` work for local inbounds. * 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 nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? ''; const vlessEncryption = useWatch({ control, name: 'settings.encryption' }) ?? '';
const ssMethod = Form.useWatch(['settings', 'method'], form); const ssMethod = useWatch({ control, name: 'settings.method' });
const isSSWith2022 = isSS2022({ const isSSWith2022 = isSS2022({
protocol, protocol,
settings: typeof ssMethod === 'string' ? { method: ssMethod } : {}, settings: typeof ssMethod === 'string' ? { method: ssMethod } : {},
}); });
const mixedUdpOn = Form.useWatch(['settings', 'udp'], form) ?? false; const mixedUdpOn = (useWatch({ control, name: 'settings.udp' }) ?? false) as boolean;
const network = Form.useWatch(['streamSettings', 'network'], form) ?? ''; const network = (useWatch({ control, name: 'streamSettings.network' }) ?? '') as string;
const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none'; const security = (useWatch({ control, name: 'streamSettings.security' }) ?? 'none') as string;
const streamEnabled = canEnableStream({ protocol }); const streamEnabled = canEnableStream({ protocol });
const sniffingSupported = canEnableSniffing({ 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 * Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no
// Tunnel's TProxy/redirect mode needs (sockopt.tproxy). Hysteria carries its * user-selectable transport — their stream tab is just sockopt, which is all
// own dedicated transport form. For all of these the RAW/mKCP/WS/... network * Tunnel's TProxy/redirect mode needs (sockopt.tproxy). Hysteria carries its
// picker and the per-network sub-forms are hidden. * own dedicated transport form. For all of these the RAW/mKCP/WS/... network
* picker and the per-network sub-forms are hidden.
*/
const hasSelectableTransport = const hasSelectableTransport =
protocol !== Protocols.HYSTERIA protocol !== Protocols.HYSTERIA
&& protocol !== Protocols.WIREGUARD && protocol !== Protocols.WIREGUARD
&& protocol !== Protocols.TUNNEL; && protocol !== Protocols.TUNNEL;
const wPort = Form.useWatch('port', form); const wPort = useWatch({ control, name: 'port' });
const wListen = (Form.useWatch('listen', form) ?? '') as string; const wListen = (useWatch({ control, name: 'listen' }) ?? '') as string;
const isUdsListen = wListen.startsWith('/') || wListen.startsWith('@'); const isUdsListen = wListen.startsWith('/') || wListen.startsWith('@');
const wNodeId = Form.useWatch('nodeId', form) ?? null; const wNodeId = useWatch({ control, name: 'nodeId' }) ?? null;
const shareAddrStrategy = Form.useWatch('shareAddrStrategy', form) ?? 'node'; const shareAddrStrategy = useWatch({ control, name: 'shareAddrStrategy' }) ?? 'node';
const wTag = Form.useWatch('tag', form) ?? ''; const wTag = (useWatch({ control, name: 'tag' }) ?? '') as string;
const wSsNetwork = Form.useWatch(['settings', 'network'], form); const wSsNetwork = useWatch({ control, name: 'settings.network' });
const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form); 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 autoTagRef = useRef(true);
const lastWrittenTagRef = useRef(''); const lastWrittenTagRef = useRef('');
const currentTagInput = (): InboundTagInput => ({ const currentTagInput = (): InboundTagInput => ({
@@ -257,27 +286,24 @@ export default function InboundFormModal({
setCertFromPanel, setCertFromPanel,
clearCertFiles, clearCertFiles,
onSecurityChange, 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) => { const toggleSockopt = (on: boolean) => {
if (on) { if (on) {
form.setFieldValue( setV('streamSettings.sockopt', SockoptStreamSettingsSchema.parse({}));
['streamSettings', 'sockopt'],
SockoptStreamSettingsSchema.parse({}),
);
} else { } 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 const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0
? Wireguard.generateKeypair(wgSecretKey).publicKey ? Wireguard.generateKeypair(wgSecretKey).publicKey
: ''; : '';
const regenInboundWg = () => { const regenInboundWg = () => {
const kp = Wireguard.generateKeypair(); const kp = Wireguard.generateKeypair();
form.setFieldValue(['settings', 'secretKey'], kp.privateKey); setV('settings.secretKey', kp.privateKey);
}; };
const matchesVlessAuth = ( const matchesVlessAuth = (
@@ -306,16 +332,16 @@ export default function InboundFormModal({
}; };
const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId)); const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId));
if (!block) return; if (!block) return;
form.setFieldValue(['settings', 'decryption'], block.decryption); setV('settings.decryption', block.decryption);
form.setFieldValue(['settings', 'encryption'], block.encryption); setV('settings.encryption', block.encryption);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const clearVlessEnc = () => { const clearVlessEnc = () => {
form.setFieldValue(['settings', 'decryption'], 'none'); setV('settings.decryption', 'none');
form.setFieldValue(['settings', 'encryption'], 'none'); setV('settings.encryption', 'none');
}; };
const vlessAuthKind = vlessEncryptionAuthKind( const vlessAuthKind = vlessEncryptionAuthKind(
@@ -333,8 +359,7 @@ export default function InboundFormModal({
const initial = mode === 'edit' && dbInbound const initial = mode === 'edit' && dbInbound
? rawInboundToFormValues(dbInbound) ? rawInboundToFormValues(dbInbound)
: buildAddModeValues(); : buildAddModeValues();
form.resetFields(); methods.reset(initial);
form.setFieldsValue(initial);
setScanResult(null); setScanResult(null);
const initialTag = (initial.tag ?? '') as string; const initialTag = (initial.tag ?? '') as string;
autoTagRef.current = isAutoInboundTag(initialTag, { autoTagRef.current = isAutoInboundTag(initialTag, {
@@ -355,77 +380,67 @@ export default function InboundFormModal({
loadFallbacks(null); loadFallbacks(null);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [open, mode, dbInbound, form]); }, [open, mode, dbInbound, methods]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (wTag === lastWrittenTagRef.current) return; if (wTag === lastWrittenTagRef.current) return;
autoTagRef.current = isAutoInboundTag(wTag, currentTagInput()); autoTagRef.current = isAutoInboundTag(wTag, currentTagInput());
// eslint-disable-next-line react-hooks/exhaustive-deps /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [open, wTag]); }, [open, wTag]);
useEffect(() => { useEffect(() => {
if (!open || !autoTagRef.current) return; if (!open || !autoTagRef.current) return;
const next = composeInboundTag(currentTagInput()); const next = composeInboundTag(currentTagInput());
if (next !== (form.getFieldValue('tag') ?? '')) { if (next !== ((getV('tag') as string | undefined) ?? '')) {
lastWrittenTagRef.current = next; 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]); }, [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 * Keep the strategy value inside the visible option set: when `node` isn't
// `listen`, which yields the same link for a local inbound. Mirrors how the * offered (no node, or a protocol that can't deploy to one) fall back to
// protocol reset drops a nodeId that no longer applies. * `listen`, which yields the same link for a local inbound. Mirrors how the
// Only downgrade once the inputs this decision depends on are settled, so a * protocol reset drops a nodeId that no longer applies.
// persisted `node` strategy is never clobbered by transient mount state (#5375): * Only downgrade once the inputs this decision depends on are settled, so a
// - `availableNodesFetched`: an empty `availableNodes` during the async * persisted `node` strategy is never clobbered by transient mount state (#5375).
// /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.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (!availableNodesFetched || !protocol) 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') { 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]); }, [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 * Protocol picker reset cascades through the form — clearing the settings DU
// legacy modal did this imperatively in onProtocolChange; here we hook * branch and dropping a nodeId that no longer applies. Only a real user
// into AntD's onValuesChange and let setFieldValue keep the rest of * change (type === 'change') triggers it; programmatic setValue (advanced
// the form state intact. * JSON edits, open reset) must not, matching the legacy onValuesChange.
const onValuesChange = (changed: Partial<InboundFormValues>) => { */
useEffect(() => {
if (mode === 'edit') return; if (mode === 'edit') return;
if ('protocol' in changed && typeof changed.protocol === 'string') { /* eslint-disable-next-line react-hooks/incompatible-library */
const next = changed.protocol; const sub = methods.watch((_value, { name, type }) => {
if (name !== 'protocol' || type !== 'change') return;
const next = getV('protocol') as string;
const settings = createDefaultInboundSettings(next) ?? undefined; const settings = createDefaultInboundSettings(next) ?? undefined;
form.setFieldValue('settings', settings); setV('settings', settings);
if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) { 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) { if (next === Protocols.HYSTERIA) {
form.setFieldValue('streamSettings', { setV('streamSettings', {
network: 'hysteria', network: 'hysteria',
security: 'tls', security: 'tls',
hysteriaSettings: HysteriaStreamSettingsSchema.parse({}), hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
tlsSettings: createHysteriaTlsSettingsWithDefaultCert(), 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: { finalmask: {
tcp: [], tcp: [],
udp: [{ udp: [{
@@ -435,37 +450,28 @@ export default function InboundFormModal({
}, },
}); });
} else if (next === Protocols.WIREGUARD || next === Protocols.TUNNEL) { } else if (next === Protocols.WIREGUARD || next === Protocols.TUNNEL) {
// Wireguard and Tunnel (dokodemo-door) have no user-selectable setV('streamSettings', { security: 'none' });
// 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' });
} else { } else {
const current = form.getFieldValue('streamSettings') as { network?: string } | undefined; const current = getV('streamSettings') as { network?: string } | undefined;
if (current?.network === 'hysteria' || !current?.network) { 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 () => { const submit = async () => {
try { if (!(await methods.trigger())) return;
await form.validateFields(); /*
} catch { * getValues() returns the entire form store, including settings.clients and
return; * settings.fallbacks which have no bound field (clients are managed via the
} * standalone Client modal, not this inbound modal). With shouldUnregister
// Why getFieldsValue(true) instead of the validateFields return value: * false those pass-through sub-trees survive from the reset object, so the
// rc-component/form's validateFields filters its output by REGISTERED * update wire payload never silently drops every client on save.
// name paths. settings.clients and settings.fallbacks have no Form.Item */
// bound to them (clients are managed via the standalone Client modal, const values = methods.getValues() as InboundFormValues;
// 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;
const parsed = InboundFormSchema.safeParse(values); const parsed = InboundFormSchema.safeParse(values);
if (!parsed.success) { if (!parsed.success) {
const issues = parsed.error.issues; const issues = parsed.error.issues;
@@ -509,24 +515,16 @@ export default function InboundFormModal({
const basicTab = ( const basicTab = (
<> <>
<Form.Item name="tag" hidden noStyle><Input /></Form.Item> <FormField name="enable" label={t('enable')} valueProp="checked">
<Form.Item name="up" hidden noStyle><InputNumber /></Form.Item>
<Form.Item name="down" hidden noStyle><InputNumber /></Form.Item>
<Form.Item name="total" hidden noStyle><InputNumber /></Form.Item>
<Form.Item name="expiryTime" hidden noStyle><InputNumber /></Form.Item>
<Form.Item name="lastTrafficResetTime" hidden noStyle><InputNumber /></Form.Item>
<Form.Item name="clientStats" hidden noStyle><Input /></Form.Item>
<Form.Item name="enable" label={t('enable')} valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item name="remark" label={t('pages.inbounds.remark')}> <FormField name="remark" label={t('pages.inbounds.remark')}>
<Input /> <Input />
</Form.Item> </FormField>
{selectableNodes.length > 0 && isNodeEligible && ( {selectableNodes.length > 0 && isNodeEligible && (
<Form.Item name="nodeId" label={t('pages.inbounds.deployTo')}> <FormField name="nodeId" label={t('pages.inbounds.deployTo')}>
<Select <Select
showSearch showSearch
disabled={mode === 'edit'} disabled={mode === 'edit'}
@@ -538,21 +536,21 @@ export default function InboundFormModal({
disabled: n.status === 'offline', disabled: n.status === 'offline',
}))} }))}
/> />
</Form.Item> </FormField>
)} )}
<Form.Item name="protocol" label={t('pages.inbounds.protocol')}> <FormField name="protocol" label={t('pages.inbounds.protocol')}>
<Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} /> <Select id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
</Form.Item> </FormField>
<Form.Item <FormField
name="listen" name="listen"
label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))} label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))}
> >
<Input placeholder={t('pages.inbounds.monitorDesc')} /> <Input placeholder={t('pages.inbounds.monitorDesc')} />
</Form.Item> </FormField>
<Form.Item <FormField
name="shareAddrStrategy" name="shareAddrStrategy"
label={labelWithHint(t('pages.inbounds.form.shareAddrStrategy'), t('pages.inbounds.form.shareAddrStrategyHelp'))} label={labelWithHint(t('pages.inbounds.form.shareAddrStrategy'), t('pages.inbounds.form.shareAddrStrategyHelp'))}
> >
@@ -564,38 +562,35 @@ export default function InboundFormModal({
label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`), label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
}))} }))}
/> />
</Form.Item> </FormField>
{shareAddrStrategy === 'custom' && ( {shareAddrStrategy === 'custom' && (
<Form.Item <FormField
name="shareAddr" name="shareAddr"
label={labelWithHint(t('pages.inbounds.form.shareAddr'), t('pages.inbounds.form.shareAddrHelp'))} label={labelWithHint(t('pages.inbounds.form.shareAddr'), t('pages.inbounds.form.shareAddrHelp'))}
rules={[{ rules={{
validator: (_, value) => ( validate: (value) =>
isValidShareAddrInput(String(value ?? '')) isValidShareAddrInput(String(value ?? '')) || t('pages.inbounds.form.shareAddrHelp'),
? Promise.resolve() }}
: Promise.reject(new Error(t('pages.inbounds.form.shareAddrHelp')))
),
}]}
> >
<Input placeholder="edge.example.com" /> <Input placeholder="edge.example.com" />
</Form.Item> </FormField>
)} )}
<Form.Item <FormField
name="subSortIndex" name="subSortIndex"
label={labelWithHint(t('pages.inbounds.form.subSortIndex'), t('pages.inbounds.form.subSortIndexHelp'))} label={labelWithHint(t('pages.inbounds.form.subSortIndex'), t('pages.inbounds.form.subSortIndexHelp'))}
> >
<InputNumber min={1} /> <InputNumber min={1} />
</Form.Item> </FormField>
<Form.Item <FormField
name="port" name="port"
label={t('pages.inbounds.port')} label={t('pages.inbounds.port')}
rules={[antdRule(InboundFormBaseSchema.shape.port, t)]} rules={{ validate: rhfZodValidate(InboundFormBaseSchema.shape.port) }}
> >
<InputNumber min={isUdsListen ? 0 : 1} max={65535} /> <InputNumber min={isUdsListen ? 0 : 1} max={65535} />
</Form.Item> </FormField>
<Form.Item <Form.Item
label={ label={
@@ -604,41 +599,25 @@ export default function InboundFormModal({
</Tooltip> </Tooltip>
} }
> >
<Form.Item <InputNumber
noStyle value={wTotal ? Math.round((wTotal / SizeFormatter.ONE_GB) * 100) / 100 : 0}
shouldUpdate={(prev, curr) => prev.total !== curr.total} min={0}
> step={1}
{({ getFieldValue, setFieldValue }) => { onChange={(v) => {
const totalBytes = (getFieldValue('total') as number) ?? 0; const bytes = NumberFormatter.toFixed((Number(v) || 0) * SizeFormatter.ONE_GB, 0);
const totalGB = totalBytes setV('total', bytes);
? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100
: 0;
return (
<InputNumber
value={totalGB}
min={0}
step={1}
onChange={(v) => {
const bytes = NumberFormatter.toFixed(
(Number(v) || 0) * SizeFormatter.ONE_GB,
0,
);
setFieldValue('total', bytes);
}}
/>
);
}} }}
</Form.Item> />
</Form.Item> </Form.Item>
<Form.Item name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}> <FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
<Select <Select
options={TRAFFIC_RESETS.map((r) => ({ options={TRAFFIC_RESETS.map((r) => ({
value: r, value: r,
label: t(`pages.inbounds.periodicTrafficReset.${r}`), label: t(`pages.inbounds.periodicTrafficReset.${r}`),
}))} }))}
/> />
</Form.Item> </FormField>
<Form.Item <Form.Item
label={ label={
@@ -647,20 +626,10 @@ export default function InboundFormModal({
</Tooltip> </Tooltip>
} }
> >
<Form.Item <DateTimePicker
noStyle value={wExpiry > 0 ? dayjs(wExpiry) : null}
shouldUpdate={(prev, curr) => prev.expiryTime !== curr.expiryTime} onChange={(d) => setV('expiryTime', d ? d.valueOf() : 0)}
> />
{({ getFieldValue, setFieldValue }) => {
const expiry = (getFieldValue('expiryTime') as number) ?? 0;
return (
<DateTimePicker
value={expiry > 0 ? dayjs(expiry) : null}
onChange={(d) => setFieldValue('expiryTime', d ? d.valueOf() : 0)}
/>
);
}}
</Form.Item>
</Form.Item> </Form.Item>
</> </>
); );
@@ -690,7 +659,7 @@ export default function InboundFormModal({
{protocol === Protocols.MTPROTO && <MtprotoFields />} {protocol === Protocols.MTPROTO && <MtprotoFields />}
{protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />} {protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields isSSWith2022={isSSWith2022} />}
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} vlessAuthKind={vlessAuthKind} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />} {protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} vlessAuthKind={vlessAuthKind} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
@@ -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 * Switching `network` swaps which per-network key appears on the wire. Clear
// network's blob and seed the new one with the schema defaults so the * the old network's blob and seed the new one with schema defaults, plus the
// Form.Items inside it have valid initial values (KCP needs MTU=1350 * FinalMask mkcp-legacy UDP mask when moving to mKCP (removed otherwise).
// 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<string, unknown> => {
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 {};
}
};
const onNetworkChange = (next: string) => { const onNetworkChange = (next: string) => {
const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings']; const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings'];
const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {}; const current = (getV('streamSettings') as Record<string, unknown>) ?? {};
const cleaned: Record<string, unknown> = { ...current, network: next }; const cleaned: Record<string, unknown> = { ...current, network: next };
for (const k of ALL) { for (const k of ALL) {
if (k !== `${next}Settings`) delete cleaned[k]; if (k !== `${next}Settings`) delete cleaned[k];
} }
cleaned[`${next}Settings`] = newStreamSlice(next); 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') { if (next === 'kcp') {
const fm = (cleaned.finalmask as Record<string, unknown> | undefined) ?? {}; const fm = (cleaned.finalmask as Record<string, unknown> | undefined) ?? {};
const udp = Array.isArray(fm.udp) ? (fm.udp as unknown[]) : []; const udp = Array.isArray(fm.udp) ? (fm.udp as unknown[]) : [];
@@ -762,15 +709,16 @@ export default function InboundFormModal({
cleaned.finalmask = { ...fm, udp }; cleaned.finalmask = { ...fm, udp };
} }
} }
form.setFieldValue('streamSettings', cleaned); setV('streamSettings', cleaned);
}; };
const streamTab = ( const streamTab = (
<> <>
{hasSelectableTransport && ( {hasSelectableTransport && (
<Form.Item label={t('transmission')} name={['streamSettings', 'network']}> <Form.Item label={t('transmission')}>
<Select <Select
style={{ width: '75%' }} style={{ width: '75%' }}
value={network}
onChange={onNetworkChange} onChange={onNetworkChange}
options={[ options={[
{ value: 'tcp', label: 'RAW' }, { value: 'tcp', label: 'RAW' },
@@ -786,12 +734,8 @@ export default function InboundFormModal({
{/* Inbound Hysteria stream sub-form. The transport for hysteria {/* Inbound Hysteria stream sub-form. The transport for hysteria
isn't user-selectable (always 'hysteria'), so the network isn't user-selectable (always 'hysteria'), so the network
dropdown is hidden above. Fields here mirror the legacy dropdown is hidden above. */}
HysteriaStreamSettings inbound class: version is locked to 2, {protocol === Protocols.HYSTERIA && <HysteriaFields />}
auth + udpIdleTimeout are required, masquerade is an optional
sub-object that lets xray-core disguise the listener as an
HTTP server when probed. */}
{protocol === Protocols.HYSTERIA && <HysteriaFields form={form} />}
{hasSelectableTransport && ( {hasSelectableTransport && (
<> <>
@@ -801,7 +745,7 @@ export default function InboundFormModal({
{network === 'grpc' && <GrpcForm />} {network === 'grpc' && <GrpcForm />}
{network === 'xhttp' && <XhttpForm form={form} />} {network === 'xhttp' && <XhttpForm />}
{network === 'httpupgrade' && <HttpUpgradeForm />} {network === 'httpupgrade' && <HttpUpgradeForm />}
@@ -813,56 +757,45 @@ export default function InboundFormModal({
field is still parsed/rendered for backward compatibility but is no field is still parsed/rendered for backward compatibility but is no
longer editable here. */} longer editable here. */}
<SockoptForm toggleSockopt={toggleSockopt} network={network as string} /> <SockoptForm toggleSockopt={toggleSockopt} network={network} />
{/* Transport masks don't apply to tunnel (a transparent forwarder), so {/* Transport masks don't apply to tunnel (a transparent forwarder), so
its stream tab is just sockopt + TProxy. */} its stream tab is just sockopt + TProxy. */}
{protocol !== Protocols.TUNNEL && ( {protocol !== Protocols.TUNNEL && (
<FinalMaskForm <Controller
name={['streamSettings', 'finalmask']} control={control}
network={network as string} name="streamSettings.finalmask"
protocol={protocol} render={({ field }) => (
form={form} <FinalMaskField
key={`${protocol}:${network}`}
value={field.value}
onChange={field.onChange}
network={network}
protocol={protocol}
/>
)}
/> />
)} )}
</> </>
); );
const tlsOk = canEnableTls({ protocol, streamSettings: { network, security } });
const realityOk = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsOnly = protocol === Protocols.HYSTERIA;
const securityTab = ( const securityTab = (
<> <>
<Form.Item name={['streamSettings', 'security']} hidden noStyle>
<Input />
</Form.Item>
<Form.Item label={t('pages.inbounds.securityTab')}> <Form.Item label={t('pages.inbounds.securityTab')}>
<Form.Item <Radio.Group
noStyle value={security}
shouldUpdate={(prev, curr) => buttonStyle="solid"
prev.streamSettings?.security !== curr.streamSettings?.security disabled={!tlsOk}
|| prev.streamSettings?.network !== curr.streamSettings?.network onChange={(e) => onSecurityChange(e.target.value)}
|| prev.protocol !== curr.protocol
}
> >
{({ getFieldValue }) => { {!tlsOnly && <Radio.Button value="none">{t('none')}</Radio.Button>}
const sec = getFieldValue(['streamSettings', 'security']) ?? 'none'; <Radio.Button value="tls">TLS</Radio.Button>
const net = getFieldValue(['streamSettings', 'network']) ?? ''; {realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
const proto = getFieldValue('protocol') ?? ''; </Radio.Group>
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 (
<Radio.Group
value={sec}
buttonStyle="solid"
disabled={!tlsOk}
onChange={(e) => onSecurityChange(e.target.value)}
>
{!tlsOnly && <Radio.Button value="none">{t('none')}</Radio.Button>}
<Radio.Button value="tls">TLS</Radio.Button>
{realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
</Radio.Group>
);
}}
</Form.Item>
</Form.Item> </Form.Item>
{security === 'tls' && ( {security === 'tls' && (
@@ -916,7 +849,7 @@ export default function InboundFormModal({
<div className="advanced-editor-meta"> <div className="advanced-editor-meta">
{t('pages.inbounds.advanced.allHelp')} {t('pages.inbounds.advanced.allHelp')}
</div> </div>
<AdvancedAllEditor form={form} streamEnabled={streamEnabled} sniffingEnabled={sniffingSupported} /> <AdvancedAllEditor streamEnabled={streamEnabled} sniffingEnabled={sniffingSupported} />
</> </>
), ),
}, },
@@ -930,7 +863,6 @@ export default function InboundFormModal({
<code>{'{ settings: { ... } }'}</code>. <code>{'{ settings: { ... } }'}</code>.
</div> </div>
<AdvancedSliceEditor <AdvancedSliceEditor
form={form}
path="settings" path="settings"
wrapKey="settings" wrapKey="settings"
minHeight="320px" minHeight="320px"
@@ -950,7 +882,6 @@ export default function InboundFormModal({
<code>{'{ streamSettings: { ... } }'}</code>. <code>{'{ streamSettings: { ... } }'}</code>.
</div> </div>
<AdvancedSliceEditor <AdvancedSliceEditor
form={form}
path="streamSettings" path="streamSettings"
wrapKey="streamSettings" wrapKey="streamSettings"
minHeight="320px" minHeight="320px"
@@ -971,7 +902,6 @@ export default function InboundFormModal({
<code>{'{ sniffing: { ... } }'}</code>. <code>{'{ sniffing: { ... } }'}</code>.
</div> </div>
<AdvancedSliceEditor <AdvancedSliceEditor
form={form}
path="sniffing" path="sniffing"
wrapKey="sniffing" wrapKey="sniffing"
minHeight="240px" minHeight="240px"
@@ -1004,50 +934,42 @@ export default function InboundFormModal({
onCancel={onClose} onCancel={onClose}
destroyOnHidden destroyOnHidden
> >
<Form <FormProvider {...methods}>
form={form} <Form
colon={false} colon={false}
labelCol={{ sm: { span: 8 } }} labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }} wrapperCol={{ sm: { span: 14 } }}
labelWrap labelWrap
onValuesChange={onValuesChange} >
> <Tabs items={[
<Tabs items={[ { key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
// forceRender on every tab so all Form.Items register at modal ...(([
// open, not lazily on first visit. Without it, AntD's items API Protocols.VLESS,
// lazy-mounts inactive tabs — their fields don't register, so Protocols.SHADOWSOCKS,
// Form.useWatch on a parent path (e.g. 'sniffing') returns the Protocols.HTTP,
// partial-view {} until the user touches the tab and the Protocols.MIXED,
// inner Form.Item for `sniffing.enabled` registers. Protocols.TUNNEL,
{ key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true }, Protocols.TUN,
...(([ Protocols.WIREGUARD,
Protocols.VLESS, Protocols.MTPROTO,
Protocols.SHADOWSOCKS, ] as string[]).includes(protocol) || isFallbackHost
Protocols.HTTP, ? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
Protocols.MIXED, : []),
Protocols.TUNNEL, ...(streamEnabled
Protocols.TUN, ? [
Protocols.WIREGUARD, { key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true },
Protocols.MTPROTO, ...(protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL
] as string[]).includes(protocol) || isFallbackHost ? [{ key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true }]
? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }] : []),
: []), ]
...(streamEnabled : []),
? [ ...(sniffingSupported
{ key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true }, ? [{ key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true }]
// Wireguard and Tunnel can't do TLS/Reality (canEnableTls is false), so : []),
// the security tab would only show a fully disabled radio. { key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab, forceRender: true },
...(protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL ]} />
? [{ key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true }] </Form>
: []), </FormProvider>
]
: []),
...(sniffingSupported
? [{ key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true }]
: []),
{ key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab, forceRender: true },
]} />
</Form>
</Modal> </Modal>
</> </>
); );
@@ -1,16 +1,22 @@
import { useTranslation } from 'react-i18next'; 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() { export default function SniffingTab() {
const { t } = useTranslation(); const { t } = useTranslation();
const form = Form.useFormInstance(); const { control } = useFormContext();
return ( return (
<SniffingFields <Controller
name={['sniffing']} control={control}
form={form} name="sniffing"
enableLabel={t('enable')} render={({ field }) => (
<SniffingField
value={field.value}
onChange={field.onChange}
enableLabel={t('enable')}
/>
)}
/> />
); );
} }
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Form, type FormInstance } from 'antd'; import { useFormContext, useWatch } from 'react-hook-form';
import type { NamePath } from 'antd/es/form/interface';
import { JsonEditor } from '@/components/form'; import { JsonEditor } from '@/components/form';
import { import {
@@ -9,46 +8,44 @@ import {
normalizeClients, normalizeClients,
dropLegacyOptionalEmpties, dropLegacyOptionalEmpties,
} from '@/lib/xray/inbound-form-adapter'; } 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 * Sub-editor for one slice of the form (settings, streamSettings, sniffing).
// we try to JSON.parse and forward the result to form state. Invalid JSON * Holds a local text buffer so the user can type freely; on every keystroke
// is held in the buffer until the next valid moment — no panic on partial * we try to JSON.parse and forward the result to form state. Invalid JSON
// input. The buffer seeds once on mount; the modal's destroyOnHidden makes * is held in the buffer until the next valid moment — no panic on partial
// each open a fresh editor instance, so we don't need to re-sync on outer * input. The buffer seeds once on mount; the modal's destroyOnHidden makes
// form changes. * each open a fresh editor instance, so we don't need to re-sync on outer
* form changes.
*/
export function AdvancedSliceEditor({ export function AdvancedSliceEditor({
form,
path, path,
wrapKey, wrapKey,
minHeight, minHeight,
maxHeight, maxHeight,
}: { }: {
form: FormInstance<InboundFormValues>; path: string;
path: NamePath; /*
// When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so * 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. * the JSON the user sees matches the wire shape's slice envelope (e.g.
// `{ "settings": { ... } }`). Edits unwrap the outer key before writing * `{ "settings": { ... } }`). Edits unwrap the outer key before writing
// back to the form. Mirrors the legacy modal's wrappedConfigValue. * back to the form. Mirrors the legacy modal's wrappedConfigValue.
*/
wrapKey?: string; wrapKey?: string;
minHeight?: string; minHeight?: string;
maxHeight?: string; maxHeight?: string;
}) { }) {
const { control, getValues, setValue } = useFormContext();
const serialize = (value: unknown): string => { const serialize = (value: unknown): string => {
const inner = value ?? {}; const inner = value ?? {};
return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2); return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2);
}; };
// preserve: true so useWatch returns the full subtree from the form const watched = useWatch({ control, name: path });
// 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 lastEmitRef = useRef<string>(''); const lastEmitRef = useRef<string>('');
const [text, setText] = useState(() => { const [text, setText] = useState(() => {
const initial = serialize(form.getFieldValue(path)); const initial = serialize(getValues(path));
lastEmitRef.current = initial; lastEmitRef.current = initial;
return initial; return initial;
}); });
@@ -58,7 +55,7 @@ export function AdvancedSliceEditor({
if (formStr === lastEmitRef.current) return; if (formStr === lastEmitRef.current) return;
setText(formStr); setText(formStr);
lastEmitRef.current = formStr; lastEmitRef.current = formStr;
// eslint-disable-next-line react-hooks/exhaustive-deps /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [watched, wrapKey]); }, [watched, wrapKey]);
return ( return (
@@ -73,48 +70,47 @@ export function AdvancedSliceEditor({
const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed) const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)[wrapKey] ?? {} ? (parsed as Record<string, unknown>)[wrapKey] ?? {}
: parsed; : parsed;
form.setFieldValue(path, toWrite); setValue(path, toWrite);
lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2); lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2);
} catch { } 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, * The "All" editor shows the full inbound JSON in one editor: top-level
// streamSettings, sniffing). Edits round-trip back to the form's slices, * connection fields plus the three nested sub-objects (settings,
// mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity * streamSettings, sniffing). Edits round-trip back to the form's slices,
// works the same way as AdvancedSliceEditor: useWatch on the slices we * mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity
// care about, lastEmitRef as the "we wrote this" guard. * works the same way as AdvancedSliceEditor: useWatch on the slices we
* care about, lastEmitRef as the "we wrote this" guard.
*/
export function AdvancedAllEditor({ export function AdvancedAllEditor({
form,
streamEnabled, streamEnabled,
sniffingEnabled, sniffingEnabled,
}: { }: {
form: FormInstance<InboundFormValues>;
streamEnabled: boolean; streamEnabled: boolean;
sniffingEnabled: boolean; sniffingEnabled: boolean;
}) { }) {
// preserve: true — default useWatch returns only registered fields, so const { control, setValue } = useFormContext();
// sub-trees we never bound (settings.clients/fallbacks, sniffing const wListen = useWatch({ control, name: 'listen' });
// defaults, etc.) wouldn't show up. preserve switches the read to const wPort = useWatch({ control, name: 'port' });
// getFieldsValue(true) which returns the full form store. const wProtocol = useWatch({ control, name: 'protocol' });
const wListen = Form.useWatch('listen', { form, preserve: true }); const wTag = useWatch({ control, name: 'tag' });
const wPort = Form.useWatch('port', { form, preserve: true }); const wSettings = useWatch({ control, name: 'settings' });
const wProtocol = Form.useWatch('protocol', { form, preserve: true }); const wSniffing = useWatch({ control, name: 'sniffing' });
const wTag = Form.useWatch('tag', { form, preserve: true }); const wStream = useWatch({ control, name: 'streamSettings' });
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 serialize = () => { 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, * Apply the same prune/normalize as the wire payload so the JSON
// disabled sniffing as { enabled: false }, finalmask dropped when * shown here is what the panel actually POSTs (no empty defaults,
// there are no masks). * disabled sniffing as { enabled: false }, finalmask dropped when
* there are no masks).
*/
const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record<string, unknown>; const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record<string, unknown>;
if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) { if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
settingsView.clients = normalizeClients(wProtocol, settingsView.clients); settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
@@ -149,7 +145,7 @@ export function AdvancedAllEditor({
if (formStr === lastEmitRef.current) return; if (formStr === lastEmitRef.current) return;
setText(formStr); setText(formStr);
lastEmitRef.current = 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]); }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled, sniffingEnabled]);
return ( return (
@@ -166,20 +162,20 @@ export function AdvancedAllEditor({
return; return;
} }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) 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)) { 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.protocol === 'string') setValue('protocol', parsed.protocol);
if (typeof parsed.tag === 'string') form.setFieldValue('tag', parsed.tag); if (typeof parsed.tag === 'string') setValue('tag', parsed.tag);
if (parsed.settings && typeof parsed.settings === 'object') { if (parsed.settings && typeof parsed.settings === 'object') {
form.setFieldValue('settings', parsed.settings); setValue('settings', parsed.settings);
} }
if (sniffingEnabled && parsed.sniffing && typeof parsed.sniffing === 'object') { 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') { if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') {
form.setFieldValue('streamSettings', parsed.streamSettings); setValue('streamSettings', parsed.streamSettings);
} }
lastEmitRef.current = next; lastEmitRef.current = next;
}} }}
@@ -1,47 +1,47 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Space } from 'antd'; import { Button, Form, Input, Space } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { RandomUtil } from '@/utils'; import { RandomUtil } from '@/utils';
import { InputAddon } from '@/components/ui'; import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
export default function AccountsList() { export default function AccountsList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({ control, name: 'settings.accounts' });
return ( return (
<Form.List name={['settings', 'accounts']}> <>
{(fields, { add, remove }) => ( <Form.Item label={t('pages.inbounds.form.accounts')}>
<> <Button
<Form.Item label={t('pages.inbounds.form.accounts')}> size="small"
<Button onClick={() => append({
size="small" user: RandomUtil.randomLowerAndNum(8),
onClick={() => add({ pass: RandomUtil.randomLowerAndNum(12),
user: RandomUtil.randomLowerAndNum(8), })}
pass: RandomUtil.randomLowerAndNum(12), >
})} <PlusOutlined /> {t('add')}
> </Button>
<PlusOutlined /> {t('add')} </Form.Item>
</Button> {fields.length > 0 && (
</Form.Item> <Form.Item wrapperCol={{ span: 24 }}>
{fields.length > 0 && ( {fields.map((field, idx) => (
<Form.Item wrapperCol={{ span: 24 }}> <Space.Compact key={field.id} className="mb-8" block>
{fields.map((field, idx) => ( <InputAddon>{String(idx + 1)}</InputAddon>
<Space.Compact key={field.key} className="mb-8" block> <FormField name={['settings', 'accounts', idx, 'user']} noStyle>
<InputAddon>{String(idx + 1)}</InputAddon> <Input placeholder={t('username')} />
<Form.Item name={[field.name, 'user']} noStyle> </FormField>
<Input placeholder={t('username')} /> <FormField name={['settings', 'accounts', idx, 'pass']} noStyle>
</Form.Item> <Input placeholder={t('password')} />
<Form.Item name={[field.name, 'pass']} noStyle> </FormField>
<Input placeholder={t('password')} /> <Button aria-label={t('remove')} onClick={() => remove(idx)}>
</Form.Item> <MinusOutlined />
<Button aria-label={t('remove')} onClick={() => remove(field.name)}> </Button>
<MinusOutlined /> </Space.Compact>
</Button> ))}
</Space.Compact> </Form.Item>
))}
</Form.Item>
)}
</>
)} )}
</Form.List> </>
); );
} }
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'; 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'; import AccountsList from './accounts-list';
export default function HttpFields() { export default function HttpFields() {
@@ -8,13 +9,13 @@ export default function HttpFields() {
return ( return (
<> <>
<AccountsList /> <AccountsList />
<Form.Item <FormField
name={['settings', 'allowTransparent']} name={['settings', 'allowTransparent']}
label={t('pages.inbounds.form.allowTransparent')} label={t('pages.inbounds.form.allowTransparent')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,128 +1,124 @@
import { useTranslation } from 'react-i18next'; 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 { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade']; const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
export default function HysteriaFields({ form }: { form: FormInstance }) { export default function HysteriaFields() {
const { t } = useTranslation(); 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 ( return (
<> <>
<Form.Item <FormField
label={t('pages.inbounds.form.version')} label={t('pages.inbounds.form.version')}
name={['streamSettings', 'hysteriaSettings', 'version']} name={['streamSettings', 'hysteriaSettings', 'version']}
> >
<InputNumber min={2} max={2} disabled /> <InputNumber min={2} max={2} disabled />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.udpIdleTimeout')} label={t('pages.inbounds.form.udpIdleTimeout')}
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']} name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
> >
<InputNumber min={2} max={600} style={{ width: '100%' }} /> <InputNumber min={2} max={600} style={{ width: '100%' }} />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.form.masquerade')}> <Form.Item label={t('pages.inbounds.form.masquerade')}>
<Form.Item shouldUpdate noStyle> <Switch
{() => { checked={!!masq}
const m = form.getFieldValue(MASQ_PATH); onChange={(checked) =>
return ( setValue(
<Switch 'streamSettings.hysteriaSettings.masquerade',
checked={!!m} checked
onChange={(checked) => ? {
form.setFieldValue( type: '', dir: '', url: '',
MASQ_PATH, rewriteHost: false, insecure: false,
checked content: '', headers: {}, statusCode: 0,
? {
type: '', dir: '', url: '',
rewriteHost: false, insecure: false,
content: '', headers: {}, statusCode: 0,
}
: undefined,
)
} }
/> : undefined,
); )
}} }
</Form.Item> />
</Form.Item> </Form.Item>
<Form.Item shouldUpdate noStyle> {masq && (
{() => { <>
const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined; <FormField
if (!m) return null; label={t('pages.inbounds.form.type')}
return ( name={[...MASQ_PATH, 'type']}
>
<Select
options={[
{ value: '', label: 'default (404 page)' },
{ value: 'proxy', label: 'proxy (reverse proxy)' },
{ value: 'file', label: 'file (serve directory)' },
{ value: 'string', label: 'string (fixed body)' },
]}
/>
</FormField>
{masqType === 'proxy' && (
<> <>
<Form.Item <FormField
label={t('pages.inbounds.form.type')} label={t('pages.inbounds.form.upstreamUrl')}
name={[...MASQ_PATH, 'type']} name={[...MASQ_PATH, 'url']}
> >
<Select <Input placeholder="https://www.example.com" />
options={[ </FormField>
{ value: '', label: 'default (404 page)' }, <FormField
{ value: 'proxy', label: 'proxy (reverse proxy)' }, label={t('pages.inbounds.form.rewriteHost')}
{ value: 'file', label: 'file (serve directory)' }, name={[...MASQ_PATH, 'rewriteHost']}
{ value: 'string', label: 'string (fixed body)' }, valueProp="checked"
]} >
/> <Switch />
</Form.Item> </FormField>
{m.type === 'proxy' && ( <FormField
<> label={t('pages.inbounds.form.skipTlsVerify')}
<Form.Item name={[...MASQ_PATH, 'insecure']}
label={t('pages.inbounds.form.upstreamUrl')} valueProp="checked"
name={[...MASQ_PATH, 'url']} >
> <Switch />
<Input placeholder="https://www.example.com" /> </FormField>
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.rewriteHost')}
name={[...MASQ_PATH, 'rewriteHost']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.skipTlsVerify')}
name={[...MASQ_PATH, 'insecure']}
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
{m.type === 'file' && (
<Form.Item
label={t('pages.inbounds.form.directory')}
name={[...MASQ_PATH, 'dir']}
>
<Input placeholder="/var/www/html" />
</Form.Item>
)}
{m.type === 'string' && (
<>
<Form.Item
label={t('pages.inbounds.form.statusCode')}
name={[...MASQ_PATH, 'statusCode']}
>
<InputNumber min={0} max={599} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.body')}
name={[...MASQ_PATH, 'content']}
>
<Input.TextArea autoSize={{ minRows: 3 }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.headers')}
name={[...MASQ_PATH, 'headers']}
>
<HeaderMapEditor mode="v1" />
</Form.Item>
</>
)}
</> </>
); )}
}} {masqType === 'file' && (
</Form.Item> <FormField
label={t('pages.inbounds.form.directory')}
name={[...MASQ_PATH, 'dir']}
>
<Input placeholder="/var/www/html" />
</FormField>
)}
{masqType === 'string' && (
<>
<FormField
label={t('pages.inbounds.form.statusCode')}
name={[...MASQ_PATH, 'statusCode']}
>
<InputNumber min={0} max={599} style={{ width: '100%' }} />
</FormField>
<FormField
label={t('pages.inbounds.form.body')}
name={[...MASQ_PATH, 'content']}
>
<Input.TextArea autoSize={{ minRows: 3 }} />
</FormField>
<FormField
label={t('pages.inbounds.form.headers')}
name={[...MASQ_PATH, 'headers']}
>
<HeaderMapEditor mode="v1" />
</FormField>
</>
)}
</>
)}
</> </>
); );
} }
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'; 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'; import AccountsList from './accounts-list';
export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) { export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
@@ -8,25 +9,25 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
return ( return (
<> <>
<AccountsList /> <AccountsList />
<Form.Item name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}> <FormField name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
<Select <Select
options={[ options={[
{ value: 'noauth', label: 'noauth' }, { value: 'noauth', label: 'noauth' },
{ value: 'password', label: 'password' }, { value: 'password', label: 'password' },
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item <FormField
name={['settings', 'udp']} name={['settings', 'udp']}
label="UDP" label="UDP"
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
{mixedUdpOn && ( {mixedUdpOn && (
<Form.Item name={['settings', 'ip']} label="UDP IP"> <FormField name={['settings', 'ip']} label="UDP IP">
<Input /> <Input />
</Form.Item> </FormField>
)} )}
</> </>
); );
@@ -1,47 +1,49 @@
import { useTranslation } from 'react-i18next'; 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'; import { useOutboundTags } from '@/api/queries/useOutboundTags';
export default function MtprotoFields() { export default function MtprotoFields() {
const { t } = useTranslation(); const { t } = useTranslation();
const form = Form.useFormInstance(); const { control } = useFormContext();
const routeThroughXray = Form.useWatch(['settings', 'routeThroughXray'], form) as boolean | undefined; const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as boolean | undefined;
const { data: outboundTags } = useOutboundTags(); const { data: outboundTags } = useOutboundTags();
return ( return (
<> <>
<Form.Item <FormField
name={['settings', 'fakeTlsDomain']} name={['settings', 'fakeTlsDomain']}
label={t('pages.inbounds.form.fakeTlsDomain')} label={t('pages.inbounds.form.fakeTlsDomain')}
tooltip={t('pages.inbounds.form.mtprotoFakeTlsDomainHint')} tooltip={t('pages.inbounds.form.mtprotoFakeTlsDomainHint')}
> >
<Input placeholder="www.cloudflare.com" /> <Input placeholder="www.cloudflare.com" />
</Form.Item> </FormField>
<Form.Item <FormField
name={['settings', 'domainFronting', 'ip']} name={['settings', 'domainFronting', 'ip']}
label={t('pages.inbounds.form.mtgDomainFrontingIp')} label={t('pages.inbounds.form.mtgDomainFrontingIp')}
tooltip={t('pages.inbounds.form.mtgDomainFrontingHint')} tooltip={t('pages.inbounds.form.mtgDomainFrontingHint')}
> >
<Input placeholder="127.0.0.1" /> <Input placeholder="127.0.0.1" />
</Form.Item> </FormField>
<Form.Item name={['settings', 'domainFronting', 'port']} label={t('pages.inbounds.form.mtgDomainFrontingPort')}> <FormField name={['settings', 'domainFronting', 'port']} label={t('pages.inbounds.form.mtgDomainFrontingPort')}>
<InputNumber min={0} max={65535} placeholder="443" style={{ width: '100%' }} /> <InputNumber min={0} max={65535} placeholder="443" style={{ width: '100%' }} />
</Form.Item> </FormField>
<Form.Item <FormField
name={['settings', 'domainFronting', 'proxyProtocol']} name={['settings', 'domainFronting', 'proxyProtocol']}
label={t('pages.inbounds.form.mtgDomainFrontingProxyProtocol')} label={t('pages.inbounds.form.mtgDomainFrontingProxyProtocol')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item <FormField
name={['settings', 'proxyProtocolListener']} name={['settings', 'proxyProtocolListener']}
label={t('pages.inbounds.form.mtgProxyProtocolListener')} label={t('pages.inbounds.form.mtgProxyProtocolListener')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item name={['settings', 'preferIp']} label={t('pages.inbounds.form.mtgPreferIp')}> <FormField name={['settings', 'preferIp']} label={t('pages.inbounds.form.mtgPreferIp')}>
<Select <Select
allowClear allowClear
placeholder="prefer-ipv6" placeholder="prefer-ipv6"
@@ -52,27 +54,27 @@ export default function MtprotoFields() {
{ value: 'only-ipv4', label: 'only-ipv4' }, { value: 'only-ipv4', label: 'only-ipv4' },
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked"> <FormField name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valueProp="checked">
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item <FormField
name={['settings', 'throttleMaxConnections']} name={['settings', 'throttleMaxConnections']}
label={t('pages.inbounds.form.mtgThrottleMaxConnections')} label={t('pages.inbounds.form.mtgThrottleMaxConnections')}
tooltip={t('pages.inbounds.form.mtgThrottleMaxConnectionsHint')} tooltip={t('pages.inbounds.form.mtgThrottleMaxConnectionsHint')}
> >
<InputNumber min={0} placeholder="0" style={{ width: '100%' }} /> <InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
</Form.Item> </FormField>
<Form.Item <FormField
name={['settings', 'routeThroughXray']} name={['settings', 'routeThroughXray']}
label={t('pages.inbounds.form.mtgRouteThroughXray')} label={t('pages.inbounds.form.mtgRouteThroughXray')}
tooltip={t('pages.inbounds.form.mtgRouteThroughXrayHint')} tooltip={t('pages.inbounds.form.mtgRouteThroughXrayHint')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
{routeThroughXray && ( {routeThroughXray && (
<Form.Item <FormField
name={['settings', 'outboundTag']} name={['settings', 'outboundTag']}
label={t('pages.inbounds.form.mtgRouteOutbound')} label={t('pages.inbounds.form.mtgRouteOutbound')}
tooltip={t('pages.inbounds.form.mtgRouteOutboundHint')} tooltip={t('pages.inbounds.form.mtgRouteOutboundHint')}
@@ -83,22 +85,22 @@ export default function MtprotoFields() {
placeholder={t('pages.inbounds.form.mtgRouteOutboundPlaceholder')} placeholder={t('pages.inbounds.form.mtgRouteOutboundPlaceholder')}
options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))} options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
/> />
</Form.Item> </FormField>
)} )}
<Form.Item <FormField
name={['settings', 'publicIpv4']} name={['settings', 'publicIpv4']}
label={t('pages.inbounds.form.mtgPublicIpv4')} label={t('pages.inbounds.form.mtgPublicIpv4')}
tooltip={t('pages.inbounds.form.mtgPublicIpHint')} tooltip={t('pages.inbounds.form.mtgPublicIpHint')}
> >
<Input allowClear placeholder="1.2.3.4" /> <Input allowClear placeholder="1.2.3.4" />
</Form.Item> </FormField>
<Form.Item <FormField
name={['settings', 'publicIpv6']} name={['settings', 'publicIpv6']}
label={t('pages.inbounds.form.mtgPublicIpv6')} label={t('pages.inbounds.form.mtgPublicIpv6')}
tooltip={t('pages.inbounds.form.mtgPublicIpHint')} tooltip={t('pages.inbounds.form.mtgPublicIpHint')}
> >
<Input allowClear placeholder="2001:db8::1" /> <Input allowClear placeholder="2001:db8::1" />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,44 +1,45 @@
import { useTranslation } from 'react-i18next'; 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 { ReloadOutlined } from '@ant-design/icons';
import { useFormContext } from 'react-hook-form';
import { RandomUtil } from '@/utils'; import { RandomUtil } from '@/utils';
import { FormField } from '@/components/form/rhf';
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks'; import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
interface ShadowsocksFieldsProps { interface ShadowsocksFieldsProps {
form: FormInstance<InboundFormValues>;
isSSWith2022: boolean; isSSWith2022: boolean;
} }
export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFieldsProps) { export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { getValues, setValue } = useFormContext();
return ( return (
<> <>
<Form.Item name={['settings', 'method']} label={t('pages.inbounds.form.encryptionMethod')}> <FormField
name={['settings', 'method']}
label={t('pages.inbounds.form.encryptionMethod')}
onAfterChange={(v) => {
setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
}}
>
<Select <Select
onChange={(v) => {
form.setFieldValue(
['settings', 'password'],
RandomUtil.randomShadowsocksPassword(v as string),
);
}}
options={SSMethodSchema.options.map((m) => ({ value: m, label: m }))} options={SSMethodSchema.options.map((m) => ({ value: m, label: m }))}
/> />
</Form.Item> </FormField>
{isSSWith2022 && ( {isSSWith2022 && (
<Form.Item label={t('password')}> <Form.Item label={t('password')}>
<Space.Compact block> <Space.Compact block>
<Form.Item name={['settings', 'password']} noStyle> <FormField name={['settings', 'password']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} /> <Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item> </FormField>
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
onClick={() => { onClick={() => {
const method = form.getFieldValue(['settings', 'method']); const method = getValues('settings.method');
form.setFieldValue( setValue(
['settings', 'password'], 'settings.password',
RandomUtil.randomShadowsocksPassword(method as string), RandomUtil.randomShadowsocksPassword(method as string),
); );
}} }}
@@ -46,7 +47,7 @@ export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFie
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
)} )}
<Form.Item name={['settings', 'network']} label={t('pages.inbounds.network')}> <FormField name={['settings', 'network']} label={t('pages.inbounds.network')}>
<Select <Select
style={{ width: 120 }} style={{ width: 120 }}
options={[ options={[
@@ -55,14 +56,14 @@ export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFie
{ value: 'udp', label: 'UDP' }, { value: 'udp', label: 'UDP' },
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item <FormField
name={['settings', 'ivCheck']} name={['settings', 'ivCheck']}
label="ivCheck" label="ivCheck"
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,84 +1,73 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Space, Tooltip } from 'antd'; import { Button, Form, Input, InputNumber, Space, Tooltip } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; 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 (
<Form.Item label={label}>
<Button aria-label={t('add')} size="small" onClick={() => append('')}>
<PlusOutlined />
</Button>
{fields.map((field, j) => (
<Space.Compact key={field.id} block className="mt-4">
<FormField name={[...name, j]} noStyle>
<Input placeholder={placeholder(j)} />
</FormField>
<Button aria-label={t('remove')} size="small" onClick={() => remove(j)}>
<MinusOutlined />
</Button>
</Space.Compact>
))}
</Form.Item>
);
}
export default function TunFields() { export default function TunFields() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item name={['settings', 'name']} label={t('pages.inbounds.info.interfaceName')}> <FormField name={['settings', 'name']} label={t('pages.inbounds.info.interfaceName')}>
<Input placeholder="xray0" /> <Input placeholder="xray0" />
</Form.Item> </FormField>
<Form.Item name={['settings', 'mtu']} label="MTU"> <FormField name={['settings', 'mtu']} label="MTU">
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.List name={['settings', 'gateway']}> <StringList
{(fields, { add, remove }) => ( name={['settings', 'gateway']}
<Form.Item label={t('pages.inbounds.info.gateway')}> label={t('pages.inbounds.info.gateway')}
<Button aria-label={t('add')} size="small" onClick={() => add('')}> placeholder={(j) => (j === 0 ? '10.0.0.1/16' : 'fc00::1/64')}
<PlusOutlined /> />
</Button> <StringList
{fields.map((field, j) => ( name={['settings', 'dns']}
<Space.Compact key={field.key} block className="mt-4"> label="DNS"
<Form.Item name={field.name} noStyle> placeholder={(j) => (j === 0 ? '1.1.1.1' : '8.8.8.8')}
<Input placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} /> />
</Form.Item> <FormField name={['settings', 'userLevel']} label={t('pages.xray.tun.userLevel')}>
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.List name={['settings', 'dns']}>
{(fields, { add, remove }) => (
<Form.Item label="DNS">
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
<PlusOutlined />
</Button>
{fields.map((field, j) => (
<Space.Compact key={field.key} block className="mt-4">
<Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} />
</Form.Item>
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item name={['settings', 'userLevel']} label={t('pages.xray.tun.userLevel')}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.List name={['settings', 'autoSystemRoutingTable']}> <StringList
{(fields, { add, remove }) => ( name={['settings', 'autoSystemRoutingTable']}
<Form.Item label={
label={ <Tooltip title={t('pages.inbounds.form.autoSystemRoutesTooltip')}>
<Tooltip title={t('pages.inbounds.form.autoSystemRoutesTooltip')}> {t('pages.inbounds.info.autoSystemRoutes')}
{t('pages.inbounds.info.autoSystemRoutes')} </Tooltip>
</Tooltip> }
} placeholder={(j) => (j === 0 ? '0.0.0.0/0' : '::/0')}
> />
<Button aria-label={t('add')} size="small" onClick={() => add('')}> <FormField
<PlusOutlined />
</Button>
{fields.map((field, j) => (
<Space.Compact key={field.key} block className="mt-4">
<Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} />
</Form.Item>
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item
name={['settings', 'autoOutboundsInterface']} name={['settings', 'autoOutboundsInterface']}
label={ label={
<Tooltip title={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}> <Tooltip title={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}>
@@ -87,7 +76,7 @@ export default function TunFields() {
} }
> >
<Input placeholder="auto" /> <Input placeholder="auto" />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,19 +1,20 @@
import { useTranslation } from 'react-i18next'; 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 { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function TunnelFields() { export default function TunnelFields() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item name={['settings', 'rewriteAddress']} label={t('pages.inbounds.form.rewriteAddress')}> <FormField name={['settings', 'rewriteAddress']} label={t('pages.inbounds.form.rewriteAddress')}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item name={['settings', 'rewritePort']} label={t('pages.inbounds.form.rewritePort')}> <FormField name={['settings', 'rewritePort']} label={t('pages.inbounds.form.rewritePort')}>
<InputNumber min={0} max={65535} /> <InputNumber min={0} max={65535} />
</Form.Item> </FormField>
<Form.Item name={['settings', 'allowedNetwork']} label={t('pages.inbounds.form.allowedNetwork')}> <FormField name={['settings', 'allowedNetwork']} label={t('pages.inbounds.form.allowedNetwork')}>
<Select <Select
options={[ options={[
{ value: 'tcp,udp', label: 'TCP, UDP' }, { value: 'tcp,udp', label: 'TCP, UDP' },
@@ -21,17 +22,17 @@ export default function TunnelFields() {
{ value: 'udp', label: 'UDP' }, { value: 'udp', label: 'UDP' },
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.portMap')} name={['settings', 'portMap']}> <FormField label={t('pages.inbounds.portMap')} name={['settings', 'portMap']}>
<HeaderMapEditor mode="v1" /> <HeaderMapEditor mode="v1" />
</Form.Item> </FormField>
<Form.Item <FormField
name={['settings', 'followRedirect']} name={['settings', 'followRedirect']}
label={t('pages.inbounds.form.followRedirect')} label={t('pages.inbounds.form.followRedirect')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd'; 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'; import { VLESS_AUTH_LABEL_KEYS, type VlessAuthKind } from '@/lib/xray/vless-encryption';
interface VlessFieldsProps { interface VlessFieldsProps {
@@ -24,6 +26,7 @@ export default function VlessFields({
clearVlessEnc, clearVlessEnc,
}: VlessFieldsProps) { }: VlessFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { control } = useFormContext();
const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519'); const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
useEffect(() => { useEffect(() => {
@@ -36,12 +39,12 @@ export default function VlessFields({
return ( return (
<> <>
<Form.Item name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}> <FormField name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}> <FormField name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.vlessAuthGenerate')}> <Form.Item label={t('pages.inbounds.vlessAuthGenerate')}>
<Space size={8} wrap> <Space size={8} wrap>
<Select <Select
@@ -66,9 +69,22 @@ export default function VlessFields({
> >
<Space.Compact block> <Space.Compact block>
{[900, 500, 900, 256].map((def, i) => ( {[900, 500, 900, 256].map((def, i) => (
<Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}> <Controller
<InputNumber min={1} style={{ width: '25%' }} /> key={i}
</Form.Item> control={control}
name={`settings.testseed.${i}`}
defaultValue={def}
render={({ field }) => (
<InputNumber
min={1}
style={{ width: '25%' }}
value={field.value as number}
onChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
/>
)}
/>
))} ))}
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd'; import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
import { FormField } from '@/components/form/rhf';
interface WireguardFieldsProps { interface WireguardFieldsProps {
wgPubKey: string; wgPubKey: string;
regenInboundWg: () => void; regenInboundWg: () => void;
@@ -13,29 +15,29 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
<> <>
<Form.Item label={t('pages.xray.wireguard.secretKey')}> <Form.Item label={t('pages.xray.wireguard.secretKey')}>
<Space.Compact block> <Space.Compact block>
<Form.Item name={['settings', 'secretKey']} noStyle> <FormField name={['settings', 'secretKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} /> <Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item> </FormField>
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundWg} /> <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundWg} />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item label={t('pages.xray.wireguard.publicKey')}> <Form.Item label={t('pages.xray.wireguard.publicKey')}>
<Input value={wgPubKey} disabled /> <Input value={wgPubKey} disabled />
</Form.Item> </Form.Item>
<Form.Item name={['settings', 'mtu']} label="MTU"> <FormField name={['settings', 'mtu']} label="MTU">
<InputNumber /> <InputNumber />
</Form.Item> </FormField>
<Form.Item name={['settings', 'dns']} label={t('pages.inbounds.info.dns')}> <FormField name={['settings', 'dns']} label={t('pages.inbounds.info.dns')}>
<Input placeholder="1.1.1.1, 1.0.0.1" /> <Input placeholder="1.1.1.1, 1.0.0.1" />
</Form.Item> </FormField>
<Form.Item <FormField
name={['settings', 'noKernelTun']} name={['settings', 'noKernelTun']}
label={t('pages.inbounds.info.noKernelTun')} label={t('pages.inbounds.info.noKernelTun')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item name={['settings', 'domainStrategy']} label={t('pages.xray.wireguard.domainStrategy')}> <FormField name={['settings', 'domainStrategy']} label={t('pages.xray.wireguard.domainStrategy')}>
<Select <Select
allowClear allowClear
options={[ options={[
@@ -46,7 +48,7 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
{ value: 'ForceIPv6v4', label: 'ForceIPv6v4' }, { value: 'ForceIPv6v4', label: 'ForceIPv6v4' },
]} ]}
/> />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd'; import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { FormField } from '@/components/form/rhf';
import { UTLS_FINGERPRINT } from '@/schemas/primitives'; import { UTLS_FINGERPRINT } from '@/schemas/primitives';
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize'; import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
import type { RealityScanResult } from '@/generated/types'; import type { RealityScanResult } from '@/generated/types';
@@ -41,43 +42,41 @@ export default function RealityForm({
const [scannerOpen, setScannerOpen] = useState(false); const [scannerOpen, setScannerOpen] = useState(false);
return ( return (
<> <>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'show']} name={['streamSettings', 'realitySettings', 'show']}
label={t('pages.inbounds.form.show')} label={t('pages.inbounds.form.show')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}> <FormField name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']} name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
label="uTLS" label="uTLS"
> >
<Select <Select
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
/> />
</Form.Item> </FormField>
<Form.Item <Form.Item
label={t('pages.inbounds.form.target')} label={t('pages.inbounds.form.target')}
tooltip={t('pages.inbounds.form.realityTargetHint')} tooltip={t('pages.inbounds.form.realityTargetHint')}
> >
<Space.Compact block style={{ display: 'flex' }}> <Space.Compact block style={{ display: 'flex' }}>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'target']} name={['streamSettings', 'realitySettings', 'target']}
noStyle noStyle
rules={[ rules={{
{ validate: (value) => {
validator: async (_, value) => { const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
const errKey = validateRealityTarget(typeof value === 'string' ? value : ''); return errKey ? errKey : true;
if (errKey) throw new Error(t(errKey));
},
}, },
]} }}
> >
<Input style={{ flex: 1 }} placeholder="example.com:443" /> <Input style={{ flex: 1 }} placeholder="example.com:443" />
</Form.Item> </FormField>
<Button icon={<RadarChartOutlined />} loading={scanning} onClick={scanRealityTarget}> <Button icon={<RadarChartOutlined />} loading={scanning} onClick={scanRealityTarget}>
{t('pages.inbounds.form.scan')} {t('pages.inbounds.form.scan')}
</Button> </Button>
@@ -116,35 +115,35 @@ export default function RealityForm({
/> />
</Form.Item> </Form.Item>
)} )}
<Form.Item label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}> <FormField label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} /> <Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'maxTimediff']} name={['streamSettings', 'realitySettings', 'maxTimediff']}
label={t('pages.inbounds.form.maxTimeDiff')} label={t('pages.inbounds.form.maxTimeDiff')}
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'minClientVer']} name={['streamSettings', 'realitySettings', 'minClientVer']}
label={t('pages.inbounds.form.minClientVer')} label={t('pages.inbounds.form.minClientVer')}
> >
<Input placeholder="25.9.11" /> <Input placeholder="25.9.11" />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'maxClientVer']} name={['streamSettings', 'realitySettings', 'maxClientVer']}
label={t('pages.inbounds.form.maxClientVer')} label={t('pages.inbounds.form.maxClientVer')}
> >
<Input placeholder="25.9.11" /> <Input placeholder="25.9.11" />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.form.shortIds')}> <Form.Item label={t('pages.inbounds.form.shortIds')}>
<Space.Compact block style={{ display: 'flex' }}> <Space.Compact block style={{ display: 'flex' }}>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'shortIds']} name={['streamSettings', 'realitySettings', 'shortIds']}
noStyle noStyle
> >
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} /> <Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
</Form.Item> </FormField>
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeShortIds} /> <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeShortIds} />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
@@ -153,27 +152,27 @@ export default function RealityForm({
tooltip={t('pages.inbounds.form.spiderXHint')} tooltip={t('pages.inbounds.form.spiderXHint')}
> >
<Space.Compact block style={{ display: 'flex' }}> <Space.Compact block style={{ display: 'flex' }}>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'settings', 'spiderX']} name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
noStyle noStyle
> >
<Input style={{ flex: 1 }} /> <Input style={{ flex: 1 }} />
</Form.Item> </FormField>
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeSpiderX} /> <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeSpiderX} />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'settings', 'publicKey']} name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
label={t('pages.inbounds.publicKey')} label={t('pages.inbounds.publicKey')}
> >
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} /> <Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'privateKey']} name={['streamSettings', 'realitySettings', 'privateKey']}
label={t('pages.inbounds.privatekey')} label={t('pages.inbounds.privatekey')}
> >
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} /> <Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
</Form.Item> </FormField>
<Form.Item label=" "> <Form.Item label=" ">
<Space> <Space>
<Button type="primary" loading={saving} onClick={genRealityKeypair}> <Button type="primary" loading={saving} onClick={genRealityKeypair}>
@@ -182,18 +181,18 @@ export default function RealityForm({
<Button danger onClick={clearRealityKeypair}>{t('clear')}</Button> <Button danger onClick={clearRealityKeypair}>{t('clear')}</Button>
</Space> </Space>
</Form.Item> </Form.Item>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'mldsa65Seed']} name={['streamSettings', 'realitySettings', 'mldsa65Seed']}
label={t('pages.inbounds.form.mldsa65Seed')} label={t('pages.inbounds.form.mldsa65Seed')}
> >
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} /> <Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify']} name={['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify']}
label={t('pages.inbounds.form.mldsa65Verify')} label={t('pages.inbounds.form.mldsa65Verify')}
> >
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} /> <Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
</Form.Item> </FormField>
<Form.Item label=" "> <Form.Item label=" ">
<Space> <Space>
<Button type="primary" loading={saving} onClick={genMldsa65}> <Button type="primary" loading={saving} onClick={genMldsa65}>
@@ -202,13 +201,13 @@ export default function RealityForm({
<Button danger onClick={clearMldsa65}>{t('clear')}</Button> <Button danger onClick={clearMldsa65}>{t('clear')}</Button>
</Space> </Space>
</Form.Item> </Form.Item>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', 'masterKeyLog']} name={['streamSettings', 'realitySettings', 'masterKeyLog']}
label={t('pages.inbounds.form.masterKeyLog')} label={t('pages.inbounds.form.masterKeyLog')}
tooltip={t('pages.inbounds.form.masterKeyLogTip')} tooltip={t('pages.inbounds.form.masterKeyLogTip')}
> >
<Input placeholder="/path/to/sslkeylog.txt" /> <Input placeholder="/path/to/sslkeylog.txt" />
</Form.Item> </FormField>
<Collapse <Collapse
style={{ marginBottom: 14 }} style={{ marginBottom: 14 }}
items={[ items={[
@@ -222,27 +221,27 @@ export default function RealityForm({
<Divider style={{ margin: '0 0 14px 0' }}> <Divider style={{ margin: '0 0 14px 0' }}>
{t(`pages.inbounds.form.${dir}`)} {t(`pages.inbounds.form.${dir}`)}
</Divider> </Divider>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', dir, 'afterBytes']} name={['streamSettings', 'realitySettings', dir, 'afterBytes']}
label={t('pages.inbounds.form.afterBytes')} label={t('pages.inbounds.form.afterBytes')}
tooltip={t('pages.inbounds.form.afterBytesTip')} tooltip={t('pages.inbounds.form.afterBytesTip')}
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', dir, 'bytesPerSec']} name={['streamSettings', 'realitySettings', dir, 'bytesPerSec']}
label={t('pages.inbounds.form.bytesPerSec')} label={t('pages.inbounds.form.bytesPerSec')}
tooltip={t('pages.inbounds.form.bytesPerSecTip')} tooltip={t('pages.inbounds.form.bytesPerSecTip')}
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'realitySettings', dir, 'burstBytesPerSec']} name={['streamSettings', 'realitySettings', dir, 'burstBytesPerSec']}
label={t('pages.inbounds.form.burstBytesPerSec')} label={t('pages.inbounds.form.burstBytesPerSec')}
tooltip={t('pages.inbounds.form.burstBytesPerSecTip')} tooltip={t('pages.inbounds.form.burstBytesPerSecTip')}
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
</div> </div>
))} ))}
</> </>
+249 -262
View File
@@ -1,7 +1,9 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd'; import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
import { CloudDownloadOutlined, FileProtectOutlined, MinusOutlined, PlusOutlined } from '@ant-design/icons'; import { CloudDownloadOutlined, FileProtectOutlined, MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
import { import {
ALPN_OPTION, ALPN_OPTION,
DOMAIN_STRATEGY_OPTION, DOMAIN_STRATEGY_OPTION,
@@ -14,6 +16,11 @@ import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'
const { TextArea } = Input; 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 { interface TlsFormProps {
saving: boolean; saving: boolean;
setCertFromPanel: (certName: number) => void; setCertFromPanel: (certName: number) => void;
@@ -24,6 +31,178 @@ interface TlsFormProps {
clearEchCert: () => void; 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 (
<div>
<FormField
name={['streamSettings', 'tlsSettings', 'certificates', index, 'useFile']}
label={`${t('certificate')} ${index + 1}`}
>
<Radio.Group buttonStyle="solid">
<Radio.Button value={true}>
{t('pages.inbounds.certificatePath')}
</Radio.Button>
<Radio.Button value={false}>
{t('pages.inbounds.certificateContent')}
</Radio.Button>
</Radio.Group>
</FormField>
{total > 1 && (
<Form.Item label=" ">
<Button size="small" danger onClick={onRemove}>
<MinusOutlined /> {t('remove')}
</Button>
</Form.Item>
)}
{useFile ? (
<>
<FormField
name={['streamSettings', 'tlsSettings', 'certificates', index, 'certificateFile']}
label={t('pages.inbounds.publicKey')}
>
<Input />
</FormField>
<FormField
name={['streamSettings', 'tlsSettings', 'certificates', index, 'keyFile']}
label={t('pages.inbounds.privatekey')}
>
<Input />
</FormField>
<Form.Item label=" ">
<Space>
<Button
type="primary"
loading={saving}
onClick={() => setCertFromPanel(index)}
>
{t('pages.inbounds.setDefaultCert')}
</Button>
<Button danger onClick={() => clearCertFiles(index)}>
{t('clear')}
</Button>
</Space>
</Form.Item>
</>
) : (
<>
<FormField
name={['streamSettings', 'tlsSettings', 'certificates', index, 'certificate']}
label={t('pages.inbounds.publicKey')}
transform={CERT_LINES_TRANSFORM}
>
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</FormField>
<FormField
name={['streamSettings', 'tlsSettings', 'certificates', index, 'key']}
label={t('pages.inbounds.privatekey')}
transform={CERT_LINES_TRANSFORM}
>
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</FormField>
</>
)}
<FormField
name={['streamSettings', 'tlsSettings', 'certificates', index, 'ocspStapling']}
label="OCSP Stapling"
>
<InputNumber min={0} suffix="s" style={{ width: '50%' }} />
</FormField>
<FormField
name={['streamSettings', 'tlsSettings', 'certificates', index, 'oneTimeLoading']}
label={t('pages.inbounds.form.oneTimeLoading')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
name={['streamSettings', 'tlsSettings', 'certificates', index, 'usage']}
label={t('pages.inbounds.form.usageOption')}
>
<Select
style={{ width: '50%' }}
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
/>
</FormField>
{usage === 'issue' && (
<FormField
name={['streamSettings', 'tlsSettings', 'certificates', index, 'buildChain']}
label={t('pages.inbounds.form.buildChain')}
valueProp="checked"
>
<Switch />
</FormField>
)}
</div>
);
}
function EchSockoptSection() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const echSockopt = useWatch({ control, name: 'streamSettings.tlsSettings.echSockopt' });
const on = !!echSockopt;
return (
<>
<Form.Item label={t('pages.inbounds.form.echSockopt')} tooltip={t('pages.inbounds.form.echSockoptTip')}>
<Switch
checked={on}
onChange={(v) =>
setValue(
'streamSettings.tlsSettings.echSockopt',
v ? SockoptStreamSettingsSchema.parse({}) : undefined,
)
}
/>
</Form.Item>
{on && (
<>
<FormField
name={['streamSettings', 'tlsSettings', 'echSockopt', 'dialerProxy']}
label={t('pages.inbounds.form.dialerProxy')}
>
<Input />
</FormField>
<FormField
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
label={t('pages.xray.wireguard.domainStrategy')}
>
<Select
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
/>
</FormField>
<FormField
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpFastOpen']}
label={t('pages.inbounds.form.tcpFastOpen')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpMptcp']}
label={t('pages.inbounds.form.multipathTcp')}
valueProp="checked"
>
<Switch />
</FormField>
</>
)}
</>
);
}
export default function TlsForm({ export default function TlsForm({
saving, saving,
setCertFromPanel, setCertFromPanel,
@@ -34,36 +213,41 @@ export default function TlsForm({
clearEchCert, clearEchCert,
}: TlsFormProps) { }: TlsFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({
control,
name: 'streamSettings.tlsSettings.certificates',
});
return ( return (
<> <>
<Form.Item name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI"> <FormField name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} /> <Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
</Form.Item> </FormField>
<Form.Item name={['streamSettings', 'tlsSettings', 'cipherSuites']} label={t('pages.inbounds.form.cipherSuites')}> <FormField name={['streamSettings', 'tlsSettings', 'cipherSuites']} label={t('pages.inbounds.form.cipherSuites')}>
<Select <Select
options={[ options={[
{ value: '', label: t('pages.inbounds.form.autoOption') }, { value: '', label: t('pages.inbounds.form.autoOption') },
...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })), ...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}> <Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
<Space.Compact block> <Space.Compact block>
<Form.Item name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle> <FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
<Select <Select
style={{ width: '50%' }} style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))} options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/> />
</Form.Item> </FormField>
<Form.Item name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle> <FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
<Select <Select
style={{ width: '50%' }} style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))} options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/> />
</Form.Item> </FormField>
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item <FormField
name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']} name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']}
label="uTLS" label="uTLS"
> >
@@ -73,16 +257,16 @@ export default function TlsForm({
...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })), ...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN"> <FormField name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
<Select <Select
mode="multiple" mode="multiple"
tokenSeparators={[',']} tokenSeparators={[',']}
style={{ width: '100%' }} style={{ width: '100%' }}
options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))} options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
/> />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'tlsSettings', 'curvePreferences']} name={['streamSettings', 'tlsSettings', 'curvePreferences']}
label={t('pages.inbounds.form.curvePreferences')} label={t('pages.inbounds.form.curvePreferences')}
tooltip={t('pages.inbounds.form.curvePreferencesTip')} tooltip={t('pages.inbounds.form.curvePreferencesTip')}
@@ -96,274 +280,77 @@ export default function TlsForm({
label: c, label: c,
}))} }))}
/> />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'tlsSettings', 'rejectUnknownSni']} name={['streamSettings', 'tlsSettings', 'rejectUnknownSni']}
label={t('pages.inbounds.form.rejectUnknownSni')} label={t('pages.inbounds.form.rejectUnknownSni')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'tlsSettings', 'disableSystemRoot']} name={['streamSettings', 'tlsSettings', 'disableSystemRoot']}
label={t('pages.inbounds.form.disableSystemRoot')} label={t('pages.inbounds.form.disableSystemRoot')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'tlsSettings', 'enableSessionResumption']} name={['streamSettings', 'tlsSettings', 'enableSessionResumption']}
label={t('pages.inbounds.form.sessionResumption')} label={t('pages.inbounds.form.sessionResumption')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.List name={['streamSettings', 'tlsSettings', 'certificates']}> <Form.Item label={t('certificate')}>
{(certFields, { add, remove }) => ( <Button
<> aria-label={t('add')}
<Form.Item label={t('certificate')}> type="primary"
<Button size="small"
aria-label={t('add')} onClick={() => append({
type="primary" useFile: true,
size="small" certificateFile: '',
onClick={() => add({ keyFile: '',
useFile: true, certificate: [],
certificateFile: '', key: [],
keyFile: '', ocspStapling: 0,
certificate: [], oneTimeLoading: false,
key: [], usage: 'encipherment',
ocspStapling: 0, buildChain: false,
oneTimeLoading: false, })}
usage: 'encipherment', >
buildChain: false, <PlusOutlined />
})} </Button>
> </Form.Item>
<PlusOutlined /> {fields.map((field, idx) => (
</Button> <CertRow
</Form.Item> key={field.id}
{certFields.map((certField, idx) => ( index={idx}
<div key={certField.key}> total={fields.length}
<Form.Item saving={saving}
name={[certField.name, 'useFile']} onRemove={() => remove(idx)}
label={`${t('certificate')} ${idx + 1}`} setCertFromPanel={setCertFromPanel}
> clearCertFiles={clearCertFiles}
<Radio.Group buttonStyle="solid"> />
<Radio.Button value={true}> ))}
{t('pages.inbounds.certificatePath')} <FormField
</Radio.Button>
<Radio.Button value={false}>
{t('pages.inbounds.certificateContent')}
</Radio.Button>
</Radio.Group>
</Form.Item>
{certFields.length > 1 && (
<Form.Item label=" ">
<Button
size="small"
danger
onClick={() => remove(certField.name)}
>
<MinusOutlined /> {t('remove')}
</Button>
</Form.Item>
)}
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
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 ? (
<>
<Form.Item
name={[certField.name, 'certificateFile']}
label={t('pages.inbounds.publicKey')}
>
<Input />
</Form.Item>
<Form.Item
name={[certField.name, 'keyFile']}
label={t('pages.inbounds.privatekey')}
>
<Input />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button
type="primary"
loading={saving}
onClick={() => setCertFromPanel(certField.name)}
>
{t('pages.inbounds.setDefaultCert')}
</Button>
<Button danger onClick={() => clearCertFiles(certField.name)}>
{t('clear')}
</Button>
</Space>
</Form.Item>
</>
) : (
<>
<Form.Item
name={[certField.name, 'certificate']}
label={t('pages.inbounds.publicKey')}
normalize={(v) => typeof v === 'string'
? v.split('\n')
: v}
getValueProps={(v) => ({
value: Array.isArray(v) ? v.join('\n') : v,
})}
>
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</Form.Item>
<Form.Item
name={[certField.name, 'key']}
label={t('pages.inbounds.privatekey')}
normalize={(v) => typeof v === 'string'
? v.split('\n')
: v}
getValueProps={(v) => ({
value: Array.isArray(v) ? v.join('\n') : v,
})}
>
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item
name={[certField.name, 'ocspStapling']}
label="OCSP Stapling"
>
<InputNumber min={0} suffix="s" style={{ width: '50%' }} />
</Form.Item>
<Form.Item
name={[certField.name, 'oneTimeLoading']}
label={t('pages.inbounds.form.oneTimeLoading')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={[certField.name, 'usage']}
label={t('pages.inbounds.form.usageOption')}
>
<Select
style={{ width: '50%' }}
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
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 (
<Form.Item
name={[certField.name, 'buildChain']}
label={t('pages.inbounds.form.buildChain')}
valuePropName="checked"
>
<Switch />
</Form.Item>
);
}}
</Form.Item>
</div>
))}
</>
)}
</Form.List>
<Form.Item
name={['streamSettings', 'tlsSettings', 'masterKeyLog']} name={['streamSettings', 'tlsSettings', 'masterKeyLog']}
label={t('pages.inbounds.form.masterKeyLog')} label={t('pages.inbounds.form.masterKeyLog')}
tooltip={t('pages.inbounds.form.masterKeyLogTip')} tooltip={t('pages.inbounds.form.masterKeyLogTip')}
> >
<Input placeholder="/path/to/sslkeylog.txt" /> <Input placeholder="/path/to/sslkeylog.txt" />
</Form.Item> </FormField>
<Form.Item <EchSockoptSection />
noStyle <FormField name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
shouldUpdate={(prev, curr) =>
!!(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 (
<>
<Form.Item label={t('pages.inbounds.form.echSockopt')} tooltip={t('pages.inbounds.form.echSockoptTip')}>
<Switch
checked={on}
onChange={(v) =>
setFieldValue(
['streamSettings', 'tlsSettings', 'echSockopt'],
v ? SockoptStreamSettingsSchema.parse({}) : undefined,
)
}
/>
</Form.Item>
{on && (
<>
<Form.Item
name={['streamSettings', 'tlsSettings', 'echSockopt', 'dialerProxy']}
label={t('pages.inbounds.form.dialerProxy')}
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
label={t('pages.xray.wireguard.domainStrategy')}
>
<Select
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpFastOpen']}
label={t('pages.inbounds.form.tcpFastOpen')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpMptcp']}
label={t('pages.inbounds.form.multipathTcp')}
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
<Form.Item name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'tlsSettings', 'settings', 'echConfigList']} name={['streamSettings', 'tlsSettings', 'settings', 'echConfigList']}
label={t('pages.inbounds.form.echConfig')} label={t('pages.inbounds.form.echConfig')}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item label=" "> <Form.Item label=" ">
<Space> <Space>
<Button type="primary" loading={saving} onClick={getNewEchCert}> <Button type="primary" loading={saving} onClick={getNewEchCert}>
@@ -377,7 +364,7 @@ export default function TlsForm({
tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')} tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
> >
<Space.Compact block> <Space.Compact block>
<Form.Item <FormField
name={['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256']} name={['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256']}
noStyle noStyle
> >
@@ -387,7 +374,7 @@ export default function TlsForm({
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')} placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
style={{ width: 'calc(100% - 64px)' }} style={{ width: 'calc(100% - 64px)' }}
/> />
</Form.Item> </FormField>
<Button <Button
icon={<FileProtectOutlined />} icon={<FileProtectOutlined />}
onClick={pinFromCert} onClick={pinFromCert}
@@ -402,13 +389,13 @@ export default function TlsForm({
/> />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item <FormField
name={['streamSettings', 'tlsSettings', 'settings', 'verifyPeerCertByName']} name={['streamSettings', 'tlsSettings', 'settings', 'verifyPeerCertByName']}
label={t('pages.inbounds.form.verifyPeerCertByName')} label={t('pages.inbounds.form.verifyPeerCertByName')}
tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}
> >
<Input placeholder="example.com" /> <Input placeholder="example.com" />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,29 +1,31 @@
import { useTranslation } from 'react-i18next'; 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() { export default function GrpcForm() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item <FormField
name={['streamSettings', 'grpcSettings', 'serviceName']} name={['streamSettings', 'grpcSettings', 'serviceName']}
label={t('pages.inbounds.form.serviceName')} label={t('pages.inbounds.form.serviceName')}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'grpcSettings', 'authority']} name={['streamSettings', 'grpcSettings', 'authority']}
label={t('pages.inbounds.form.authority')} label={t('pages.inbounds.form.authority')}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'grpcSettings', 'multiMode']} name={['streamSettings', 'grpcSettings', 'multiMode']}
label={t('pages.inbounds.form.multiMode')} label={t('pages.inbounds.form.multiMode')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,37 +1,38 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input, Switch } from 'antd'; import { Input, Switch } from 'antd';
import { HeaderMapEditor } from '@/components/form'; import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function HttpUpgradeForm() { export default function HttpUpgradeForm() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item <FormField
name={['streamSettings', 'httpupgradeSettings', 'acceptProxyProtocol']} name={['streamSettings', 'httpupgradeSettings', 'acceptProxyProtocol']}
label={t('pages.inbounds.form.proxyProtocol')} label={t('pages.inbounds.form.proxyProtocol')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'httpupgradeSettings', 'host']} name={['streamSettings', 'httpupgradeSettings', 'host']}
label={t('host')} label={t('host')}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'httpupgradeSettings', 'path']} name={['streamSettings', 'httpupgradeSettings', 'path']}
label={t('path')} label={t('path')}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.headers')} label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'httpupgradeSettings', 'headers']} name={['streamSettings', 'httpupgradeSettings', 'headers']}
> >
<HeaderMapEditor mode="v1" /> <HeaderMapEditor mode="v1" />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,34 +1,36 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, InputNumber } from 'antd'; import { InputNumber } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function KcpForm() { export default function KcpForm() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item name={['streamSettings', 'kcpSettings', 'mtu']} label="MTU"> <FormField name={['streamSettings', 'kcpSettings', 'mtu']} label="MTU">
<InputNumber min={576} max={1460} /> <InputNumber min={576} max={1460} />
</Form.Item> </FormField>
<Form.Item name={['streamSettings', 'kcpSettings', 'tti']} label={t('pages.inbounds.form.ttiMs')}> <FormField name={['streamSettings', 'kcpSettings', 'tti']} label={t('pages.inbounds.form.ttiMs')}>
<InputNumber min={10} max={100} /> <InputNumber min={10} max={100} />
</Form.Item> </FormField>
<Form.Item name={['streamSettings', 'kcpSettings', 'uplinkCapacity']} label={t('pages.inbounds.form.uplinkMbps')}> <FormField name={['streamSettings', 'kcpSettings', 'uplinkCapacity']} label={t('pages.inbounds.form.uplinkMbps')}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item name={['streamSettings', 'kcpSettings', 'downlinkCapacity']} label={t('pages.inbounds.form.downlinkMbps')}> <FormField name={['streamSettings', 'kcpSettings', 'downlinkCapacity']} label={t('pages.inbounds.form.downlinkMbps')}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'kcpSettings', 'cwndMultiplier']} name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
label={t('pages.inbounds.form.cwndMultiplier')} label={t('pages.inbounds.form.cwndMultiplier')}
> >
<InputNumber min={1} /> <InputNumber min={1} />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'kcpSettings', 'maxSendingWindow']} name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
label={t('pages.inbounds.form.maxSendingWindow')} label={t('pages.inbounds.form.maxSendingWindow')}
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,157 +1,115 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input, Switch } from 'antd'; import { Form, Input, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form'; import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function RawForm() { export default function RawForm() {
const { t } = useTranslation(); const { t } = useTranslation();
const { control, setValue } = useFormContext();
const headerType = (useWatch({
control,
name: 'streamSettings.tcpSettings.header.type',
}) ?? 'none') as string;
return ( return (
<> <>
<Form.Item <FormField
name={['streamSettings', 'tcpSettings', 'acceptProxyProtocol']} name={['streamSettings', 'tcpSettings', 'acceptProxyProtocol']}
label={t('pages.inbounds.form.proxyProtocol')} label={t('pages.inbounds.form.proxyProtocol')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item label={`HTTP ${t('camouflage')}`}> <Form.Item label={`HTTP ${t('camouflage')}`}>
<Form.Item <Switch
noStyle checked={headerType === 'http'}
shouldUpdate={(prev, curr) => onChange={(v) => {
prev.streamSettings?.tcpSettings?.header?.type setValue(
!== curr.streamSettings?.tcpSettings?.header?.type 'streamSettings.tcpSettings.header',
} v
> ? {
{({ getFieldValue, setFieldValue }) => { type: 'http',
const headerType = getFieldValue( request: {
['streamSettings', 'tcpSettings', 'header', 'type'], version: '1.1',
) as string | undefined; method: 'GET',
return ( path: ['/'],
<Switch headers: {},
checked={headerType === 'http'} },
onChange={(v) => { response: {
setFieldValue( version: '1.1',
['streamSettings', 'tcpSettings', 'header'], status: '200',
v reason: 'OK',
? { headers: {},
type: 'http', },
request: { }
version: '1.1', : { type: 'none' },
method: 'GET',
path: ['/'],
headers: {},
},
response: {
version: '1.1',
status: '200',
reason: 'OK',
headers: {},
},
}
: { type: 'none' },
);
}}
/>
); );
}} }}
</Form.Item> />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
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 (
<>
<Form.Item
label={t('pages.inbounds.form.requestVersion')}
name={[
'streamSettings', 'tcpSettings', 'header',
'request', 'version',
]}
>
<Input placeholder="1.1" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.requestMethod')}
name={[
'streamSettings', 'tcpSettings', 'header',
'request', 'method',
]}
>
<Input placeholder="GET" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.requestPath')}
name={[
'streamSettings', 'tcpSettings', 'header',
'request', 'path',
]}
getValueProps={(v) => ({ 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 : ['/'];
}}
>
<Input placeholder="/" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.requestHeaders')}
name={[
'streamSettings', 'tcpSettings', 'header',
'request', 'headers',
]}
>
<HeaderMapEditor mode="v2" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.responseVersion')}
name={[
'streamSettings', 'tcpSettings', 'header',
'response', 'version',
]}
>
<Input placeholder="1.1" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.responseStatus')}
name={[
'streamSettings', 'tcpSettings', 'header',
'response', 'status',
]}
>
<Input placeholder="200" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.responseReason')}
name={[
'streamSettings', 'tcpSettings', 'header',
'response', 'reason',
]}
>
<Input placeholder="OK" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.responseHeaders')}
name={[
'streamSettings', 'tcpSettings', 'header',
'response', 'headers',
]}
>
<HeaderMapEditor mode="v2" />
</Form.Item>
</>
);
}}
</Form.Item> </Form.Item>
{headerType === 'http' && (
<>
<FormField
label={t('pages.inbounds.form.requestVersion')}
name={['streamSettings', 'tcpSettings', 'header', 'request', 'version']}
>
<Input placeholder="1.1" />
</FormField>
<FormField
label={t('pages.inbounds.form.requestMethod')}
name={['streamSettings', 'tcpSettings', 'header', 'request', 'method']}
>
<Input placeholder="GET" />
</FormField>
<FormField
label={t('pages.inbounds.form.requestPath')}
name={['streamSettings', 'tcpSettings', 'header', 'request', 'path']}
transform={{
input: (v) => (Array.isArray(v) ? v.join(',') : v),
output: (raw) => {
const parts = String(raw ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return parts.length > 0 ? parts : ['/'];
},
}}
>
<Input placeholder="/" />
</FormField>
<FormField
label={t('pages.inbounds.form.requestHeaders')}
name={['streamSettings', 'tcpSettings', 'header', 'request', 'headers']}
>
<HeaderMapEditor mode="v2" />
</FormField>
<FormField
label={t('pages.inbounds.form.responseVersion')}
name={['streamSettings', 'tcpSettings', 'header', 'response', 'version']}
>
<Input placeholder="1.1" />
</FormField>
<FormField
label={t('pages.inbounds.form.responseStatus')}
name={['streamSettings', 'tcpSettings', 'header', 'response', 'status']}
>
<Input placeholder="200" />
</FormField>
<FormField
label={t('pages.inbounds.form.responseReason')}
name={['streamSettings', 'tcpSettings', 'header', 'response', 'reason']}
>
<Input placeholder="OK" />
</FormField>
<FormField
label={t('pages.inbounds.form.responseHeaders')}
name={['streamSettings', 'tcpSettings', 'header', 'response', 'headers']}
>
<HeaderMapEditor mode="v2" />
</FormField>
</>
)}
</> </>
); );
} }
@@ -1,19 +1,21 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd'; 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'; import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
// Transport key that carries its own acceptProxyProtocol field (mirrored /* Transport key that carries its own acceptProxyProtocol field (mirrored
// alongside the sockopt-level one so the PROXY preset never silently no-ops). alongside the sockopt-level one so the PROXY preset never silently no-ops). */
const TRANSPORT_PROXY_FIELD: Record<string, string> = { const TRANSPORT_PROXY_FIELD: Record<string, string> = {
tcp: 'tcpSettings', tcp: 'tcpSettings',
ws: 'wsSettings', ws: 'wsSettings',
httpupgrade: 'httpupgradeSettings', httpupgrade: 'httpupgradeSettings',
}; };
// Transports on which xray-core honors sockopt.trustedXForwardedFor. gRPC joined /* 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 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. instead of the old x-real-ip gRPC metadata. */
const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp', 'grpc']; const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp', 'grpc'];
type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy'; type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy';
@@ -26,252 +28,207 @@ export default function SockoptForm({
network: string; network: string;
}) { }) {
const { t } = useTranslation(); 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, const transportField = TRANSPORT_PROXY_FIELD[network];
// picking the mechanism xray-core actually honors for the chosen transport: const sockAcceptPP = useWatch({ control, name: 'streamSettings.sockopt.acceptProxyProtocol' });
// CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp/grpc) or the const sockTrusted = useWatch({ control, name: 'streamSettings.sockopt.trustedXForwardedFor' });
// PROXY-protocol header via acceptProxyProtocol (every transport but mKCP). const transportAcceptPP = useWatch({
const applyRealClientIpPreset = ( control,
preset: RealClientIpPreset, name: transportField ? `streamSettings.${transportField}.acceptProxyProtocol` : 'streamSettings.__noTransportProxyField',
getFieldValue: (name: (string | number)[]) => unknown, });
setFieldValue: (name: (string | number)[], value: unknown) => void,
) => { /* Presets write the same sockopt fields the user could set by hand below,
const sockopt = getFieldValue(['streamSettings', 'sockopt']); 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 = const sockoptOn =
!!sockopt && typeof sockopt === 'object' && Object.keys(sockopt as object).length > 0; !!sockopt && typeof sockopt === 'object' && Object.keys(sockopt as object).length > 0;
if (preset !== 'off' && !sockoptOn) { if (preset !== 'off' && !sockoptOn) {
toggleSockopt(true); toggleSockopt(true);
} }
const transportField = TRANSPORT_PROXY_FIELD[network];
if (preset === 'off') { if (preset === 'off') {
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []); setValue('streamSettings.sockopt.trustedXForwardedFor', []);
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false); setValue('streamSettings.sockopt.acceptProxyProtocol', false);
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false); if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, false);
return; return;
} }
if (preset === 'cloudflare') { if (preset === 'cloudflare') {
const current = getFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor']); const current = getValues('streamSettings.sockopt.trustedXForwardedFor');
const list = Array.isArray(current) ? [...(current as string[])] : []; const list = Array.isArray(current) ? [...(current as string[])] : [];
if (!list.includes('CF-Connecting-IP')) list.push('CF-Connecting-IP'); if (!list.includes('CF-Connecting-IP')) list.push('CF-Connecting-IP');
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], list); setValue('streamSettings.sockopt.trustedXForwardedFor', list);
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false); setValue('streamSettings.sockopt.acceptProxyProtocol', false);
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false); if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, false);
return; return;
} }
// proxy — clear trustedXForwardedFor so a lingering header can't override the /* proxy — clear trustedXForwardedFor so a lingering header can't override the
// PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp/grpc). PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp/grpc). */
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []); setValue('streamSettings.sockopt.trustedXForwardedFor', []);
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], true); setValue('streamSettings.sockopt.acceptProxyProtocol', true);
if (transportField) setFieldValue(['streamSettings', transportField, '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 ( return (
<Form.Item <>
noStyle <Form.Item label="Sockopt">
shouldUpdate={(prev, curr) => { <Switch checked={on} onChange={toggleSockopt} aria-label="Sockopt" />
const a = (prev.streamSettings as { sockopt?: object } | undefined)?.sockopt; </Form.Item>
const b = (curr.streamSettings as { sockopt?: object } | undefined)?.sockopt; {on && (
return !!a !== !!b; <>
}} <Form.Item
> label={t('pages.inbounds.form.realClientIp')}
{({ getFieldValue }) => { tooltip={t('pages.inbounds.form.realClientIpHint')}
const sock = getFieldValue(['streamSettings', 'sockopt']); >
const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0; <Segmented
return ( value={presetValue}
<> onChange={(v) => applyRealClientIpPreset(v as RealClientIpPreset)}
<Form.Item label="Sockopt"> options={[
<Switch checked={on} onChange={toggleSockopt} aria-label="Sockopt" /> { value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
</Form.Item> { value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
{on && ( { value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
<> ]}
<Form.Item />
noStyle </Form.Item>
shouldUpdate={(prev, curr) => { {trustedMismatch && (
type ProxyWatch = { <Alert
streamSettings?: { type="warning"
sockopt?: { trustedXForwardedFor?: unknown; acceptProxyProtocol?: unknown }; showIcon
tcpSettings?: { acceptProxyProtocol?: unknown }; style={{ marginBottom: 16 }}
wsSettings?: { acceptProxyProtocol?: unknown }; title={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
httpupgradeSettings?: { acceptProxyProtocol?: unknown }; />
}; )}
}; {proxyMismatch && (
const pick = (v: ProxyWatch) => { <Alert
const s = v.streamSettings; type="warning"
return JSON.stringify([ showIcon
s?.sockopt?.trustedXForwardedFor, style={{ marginBottom: 16 }}
s?.sockopt?.acceptProxyProtocol, title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
s?.tcpSettings?.acceptProxyProtocol, />
s?.wsSettings?.acceptProxyProtocol, )}
s?.httpupgradeSettings?.acceptProxyProtocol, <FormField name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
]); <InputNumber min={0} />
}; </FormField>
return pick(prev as ProxyWatch) !== pick(curr as ProxyWatch); <FormField
}} name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
> label={t('pages.inbounds.form.tcpKeepAliveInterval')}
{({ getFieldValue, setFieldValue }) => { >
const sockopt = (getFieldValue(['streamSettings', 'sockopt']) ?? {}) as Record< <InputNumber min={0} />
string, </FormField>
unknown <FormField
>; name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
const transportField = TRANSPORT_PROXY_FIELD[network]; label={t('pages.inbounds.form.tcpKeepAliveIdle')}
const transportPP = transportField >
? getFieldValue(['streamSettings', transportField, 'acceptProxyProtocol']) === true <InputNumber min={0} />
: false; </FormField>
const proxyOn = sockopt.acceptProxyProtocol === true || transportPP; <FormField name={['streamSettings', 'sockopt', 'tcpMaxSeg']} label={t('pages.inbounds.form.tcpMaxSeg')}>
const trusted = Array.isArray(sockopt.trustedXForwardedFor) <InputNumber min={0} />
? (sockopt.trustedXForwardedFor as string[]) </FormField>
: []; <FormField
const value: RealClientIpPreset = proxyOn name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
? 'proxy' label={t('pages.inbounds.form.tcpUserTimeout')}
: trusted.length > 0 >
? 'cloudflare' <InputNumber min={0} />
: 'off'; </FormField>
const trustedMismatch = <FormField
trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network); name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
const proxyMismatch = proxyOn && network === 'kcp'; label={t('pages.inbounds.form.tcpWindowClamp')}
return ( tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
<> >
<Form.Item <InputNumber min={0} />
label={t('pages.inbounds.form.realClientIp')} </FormField>
tooltip={t('pages.inbounds.form.realClientIpHint')} <FormField
> name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
<Segmented label={t('pages.inbounds.form.proxyProtocol')}
value={value} tooltip={t('pages.inbounds.form.proxyProtocolHint')}
onChange={(v) => valueProp="checked"
applyRealClientIpPreset(v as RealClientIpPreset, getFieldValue, setFieldValue) >
} <Switch />
options={[ </FormField>
{ value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') }, <FormField
{ value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') }, name={['streamSettings', 'sockopt', 'tcpFastOpen']}
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') }, label={t('pages.inbounds.form.tcpFastOpen')}
]} valueProp="checked"
/> >
</Form.Item> <Switch />
{trustedMismatch && ( </FormField>
<Alert <FormField
type="warning" name={['streamSettings', 'sockopt', 'penetrate']}
showIcon label={t('pages.inbounds.form.penetrate')}
style={{ marginBottom: 16 }} valueProp="checked"
title={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')} >
/> <Switch />
)} </FormField>
{proxyMismatch && ( <FormField
<Alert name={['streamSettings', 'sockopt', 'V6Only']}
type="warning" label={t('pages.inbounds.form.v6Only')}
showIcon valueProp="checked"
style={{ marginBottom: 16 }} >
title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')} <Switch />
/> </FormField>
)} <FormField
</> name={['streamSettings', 'sockopt', 'tcpcongestion']}
); label={t('pages.inbounds.form.tcpCongestion')}
}} >
</Form.Item> <Select
<Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}> style={{ width: '50%' }}
<InputNumber min={0} /> options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
</Form.Item> />
<Form.Item </FormField>
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']} <FormField name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
label={t('pages.inbounds.form.tcpKeepAliveInterval')} <Select
> style={{ width: '50%' }}
<InputNumber min={0} /> options={[
</Form.Item> { value: 'off', label: 'Off' },
<Form.Item { value: 'redirect', label: 'Redirect' },
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']} { value: 'tproxy', label: 'TProxy' },
label={t('pages.inbounds.form.tcpKeepAliveIdle')} ]}
> />
<InputNumber min={0} /> </FormField>
</Form.Item> <FormField
<Form.Item name={['streamSettings', 'sockopt', 'tcpMaxSeg']} label={t('pages.inbounds.form.tcpMaxSeg')}> name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
<InputNumber min={0} /> label={t('pages.inbounds.form.trustedXForwardedFor')}
</Form.Item> tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
<Form.Item >
name={['streamSettings', 'sockopt', 'tcpUserTimeout']} <Select
label={t('pages.inbounds.form.tcpUserTimeout')} mode="tags"
> style={{ width: '100%' }}
<InputNumber min={0} /> tokenSeparators={[',']}
</Form.Item> options={[
<Form.Item { value: 'CF-Connecting-IP', label: 'CF-Connecting-IP' },
name={['streamSettings', 'sockopt', 'tcpWindowClamp']} { value: 'X-Real-IP', label: 'X-Real-IP' },
label={t('pages.inbounds.form.tcpWindowClamp')} { value: 'True-Client-IP', label: 'True-Client-IP' },
tooltip={t('pages.inbounds.form.tcpWindowClampHint')} { value: 'X-Client-IP', label: 'X-Client-IP' },
> ]}
<InputNumber min={0} /> />
</Form.Item> </FormField>
<Form.Item <Controller
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']} control={control}
label={t('pages.inbounds.form.proxyProtocol')} name="streamSettings.sockopt.customSockopt"
tooltip={t('pages.inbounds.form.proxyProtocolHint')} render={({ field }) => (
valuePropName="checked" <SockoptCustomField value={field.value} onChange={field.onChange} />
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
label={t('pages.inbounds.form.tcpFastOpen')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'penetrate']}
label={t('pages.inbounds.form.penetrate')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'V6Only']}
label={t('pages.inbounds.form.v6Only')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpcongestion']}
label={t('pages.inbounds.form.tcpCongestion')}
>
<Select
style={{ width: '50%' }}
options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
/>
</Form.Item>
<Form.Item name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
<Select
style={{ width: '50%' }}
options={[
{ value: 'off', label: 'Off' },
{ value: 'redirect', label: 'Redirect' },
{ value: 'tproxy', label: 'TProxy' },
]}
/>
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
label={t('pages.inbounds.form.trustedXForwardedFor')}
tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
>
<Select
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',']}
options={[
{ value: 'CF-Connecting-IP', label: 'CF-Connecting-IP' },
{ value: 'X-Real-IP', label: 'X-Real-IP' },
{ value: 'True-Client-IP', label: 'True-Client-IP' },
{ value: 'X-Client-IP', label: 'X-Client-IP' },
]}
/>
</Form.Item>
<CustomSockoptList />
</>
)} )}
</> />
); </>
}} )}
</Form.Item> </>
); );
} }
@@ -1,37 +1,38 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Switch } from 'antd'; import { Input, InputNumber, Switch } from 'antd';
import { HeaderMapEditor } from '@/components/form'; import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function WsForm() { export default function WsForm() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item <FormField
name={['streamSettings', 'wsSettings', 'acceptProxyProtocol']} name={['streamSettings', 'wsSettings', 'acceptProxyProtocol']}
label={t('pages.inbounds.form.proxyProtocol')} label={t('pages.inbounds.form.proxyProtocol')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item name={['streamSettings', 'wsSettings', 'host']} label={t('host')}> <FormField name={['streamSettings', 'wsSettings', 'host']} label={t('host')}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item name={['streamSettings', 'wsSettings', 'path']} label={t('path')}> <FormField name={['streamSettings', 'wsSettings', 'path']} label={t('path')}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'wsSettings', 'heartbeatPeriod']} name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
label={t('pages.inbounds.form.heartbeatPeriod')} label={t('pages.inbounds.form.heartbeatPeriod')}
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.headers')} label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'wsSettings', 'headers']} name={['streamSettings', 'wsSettings', 'headers']}
> >
<HeaderMapEditor mode="v1" /> <HeaderMapEditor mode="v1" />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,39 +1,53 @@
import { useTranslation } from 'react-i18next'; 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 { 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 { XHTTP_SESSION_ID_TABLES, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id'; import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
export default function XhttpForm({ form }: { form: FormInstance<InboundFormValues> }) { function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
return async (value: unknown): Promise<true | string> => {
try {
await fn(undefined, value);
return true;
} catch (e) {
return (e as Error).message;
}
};
}
export default function XhttpForm() {
const { t } = useTranslation(); const { t } = useTranslation();
const xhttpMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'mode'], form); const { control, getValues, setValue } = useFormContext();
const xhttpObfsMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'xPaddingObfsMode'], form) ?? false; const xhttpMode = useWatch({ control, name: 'streamSettings.xhttpSettings.mode' }) as string | undefined;
const xhttpSessionIDPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionIDPlacement'], form); const xhttpObfsMode = !!useWatch({ control, name: 'streamSettings.xhttpSettings.xPaddingObfsMode' });
const xhttpSessionIDTable = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionIDTable'], form); const xhttpSessionIDPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDPlacement' }) as string | undefined;
const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form); const xhttpSessionIDTable = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDTable' });
const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form); 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) { function onXmuxToggle(checked: boolean) {
if (!checked) return; 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; const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
if (hasValues) return; if (hasValues) return;
form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS }); setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
} }
return ( return (
<> <>
<Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}> <FormField name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item name={['streamSettings', 'xhttpSettings', 'path']} label={t('path')}> <FormField name={['streamSettings', 'xhttpSettings', 'path']} label={t('path')}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item name={['streamSettings', 'xhttpSettings', 'mode']} label={t('pages.inbounds.info.mode')}> <FormField name={['streamSettings', 'xhttpSettings', 'mode']} label={t('pages.inbounds.info.mode')}>
<Select <Select
style={{ width: '50%' }} style={{ width: '50%' }}
options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({ options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
@@ -41,64 +55,64 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
label: m, label: m,
}))} }))}
/> />
</Form.Item> </FormField>
{(xhttpMode === 'packet-up' || xhttpMode === 'auto') && ( {(xhttpMode === 'packet-up' || xhttpMode === 'auto') && (
<> <>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']} name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
label={t('pages.inbounds.form.maxUploadSize')} label={t('pages.inbounds.form.maxUploadSize')}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']} name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
label={t('pages.inbounds.form.maxBufferedUpload')} label={t('pages.inbounds.form.maxBufferedUpload')}
> >
<InputNumber /> <InputNumber />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']} name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
label={t('pages.xray.outboundForm.minUploadInterval')} label={t('pages.xray.outboundForm.minUploadInterval')}
> >
<Input placeholder="e.g. 50-150" /> <Input placeholder="e.g. 50-150" />
</Form.Item> </FormField>
</> </>
)} )}
{xhttpMode === 'stream-up' && ( {xhttpMode === 'stream-up' && (
<> <>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']} name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
label={t('pages.inbounds.form.maxBufferedUpload')} label={t('pages.inbounds.form.maxBufferedUpload')}
> >
<InputNumber /> <InputNumber />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'scStreamUpServerSecs']} name={['streamSettings', 'xhttpSettings', 'scStreamUpServerSecs']}
label={t('pages.inbounds.form.streamUpServer')} label={t('pages.inbounds.form.streamUpServer')}
> >
<Input /> <Input />
</Form.Item> </FormField>
</> </>
)} )}
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'serverMaxHeaderBytes']} name={['streamSettings', 'xhttpSettings', 'serverMaxHeaderBytes']}
label={t('pages.inbounds.form.serverMaxHeaderBytes')} label={t('pages.inbounds.form.serverMaxHeaderBytes')}
> >
<InputNumber min={0} placeholder="0 (default)" /> <InputNumber min={0} placeholder="0 (default)" />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']} name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
label={t('pages.inbounds.form.paddingBytes')} label={t('pages.inbounds.form.paddingBytes')}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'headers']} name={['streamSettings', 'xhttpSettings', 'headers']}
label={t('pages.inbounds.form.headers')} label={t('pages.inbounds.form.headers')}
> >
<HeaderMapEditor mode="v1" /> <HeaderMapEditor mode="v1" />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']} name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
label={t('pages.inbounds.form.uplinkHttpMethod')} label={t('pages.inbounds.form.uplinkHttpMethod')}
> >
@@ -114,29 +128,29 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
}, },
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']} name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
label={t('pages.inbounds.form.paddingObfsMode')} label={t('pages.inbounds.form.paddingObfsMode')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
{xhttpObfsMode && ( {xhttpObfsMode && (
<> <>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'xPaddingKey']} name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
label={t('pages.inbounds.form.paddingKey')} label={t('pages.inbounds.form.paddingKey')}
> >
<Input placeholder="x_padding" /> <Input placeholder="x_padding" />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']} name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
label={t('pages.inbounds.form.paddingHeader')} label={t('pages.inbounds.form.paddingHeader')}
> >
<Input placeholder="X-Padding" /> <Input placeholder="X-Padding" />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']} name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
label={t('pages.inbounds.form.paddingPlacement')} label={t('pages.inbounds.form.paddingPlacement')}
> >
@@ -149,8 +163,8 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
{ value: 'query', label: 'query' }, { value: 'query', label: 'query' },
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']} name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
label={t('pages.inbounds.form.paddingMethod')} label={t('pages.inbounds.form.paddingMethod')}
> >
@@ -161,10 +175,10 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
{ value: 'tokenish', label: 'tokenish' }, { value: 'tokenish', label: 'tokenish' },
]} ]}
/> />
</Form.Item> </FormField>
</> </>
)} )}
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']} name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
label={t('pages.inbounds.form.sessionPlacement')} label={t('pages.inbounds.form.sessionPlacement')}
> >
@@ -177,38 +191,38 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
{ value: 'query', label: 'query' }, { value: 'query', label: 'query' },
]} ]}
/> />
</Form.Item> </FormField>
{xhttpSessionIDPlacement && xhttpSessionIDPlacement !== 'path' && ( {xhttpSessionIDPlacement && xhttpSessionIDPlacement !== 'path' && (
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'sessionIDKey']} name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
label={t('pages.inbounds.form.sessionKey')} label={t('pages.inbounds.form.sessionKey')}
> >
<Input placeholder="x_session" /> <Input placeholder="x_session" />
</Form.Item> </FormField>
)} )}
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'sessionIDTable']} name={['streamSettings', 'xhttpSettings', 'sessionIDTable']}
label={t('pages.inbounds.form.sessionIDTable')} label={t('pages.inbounds.form.sessionIDTable')}
tooltip={t('pages.inbounds.form.sessionIDTableHint')} tooltip={t('pages.inbounds.form.sessionIDTableHint')}
rules={[{ validator: validateSessionIDTable }]} rules={{ validate: antdValidatorToRhf(validateSessionIDTable) }}
> >
<AutoComplete <AutoComplete
allowClear allowClear
options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))} options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))}
placeholder="Base62" placeholder="Base62"
/> />
</Form.Item> </FormField>
{xhttpSessionIDTable && ( {!!xhttpSessionIDTable && (
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'sessionIDLength']} name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
label={t('pages.inbounds.form.sessionIDLength')} label={t('pages.inbounds.form.sessionIDLength')}
tooltip={t('pages.inbounds.form.sessionIDLengthHint')} tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
rules={[{ validator: validateSessionIDLength }]} rules={{ validate: antdValidatorToRhf(validateSessionIDLength) }}
> >
<Input placeholder="8-16" /> <Input placeholder="8-16" />
</Form.Item> </FormField>
)} )}
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'seqPlacement']} name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
label={t('pages.inbounds.form.sequencePlacement')} label={t('pages.inbounds.form.sequencePlacement')}
> >
@@ -221,18 +235,18 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
{ value: 'query', label: 'query' }, { value: 'query', label: 'query' },
]} ]}
/> />
</Form.Item> </FormField>
{xhttpSeqPlacement && xhttpSeqPlacement !== 'path' && ( {xhttpSeqPlacement && xhttpSeqPlacement !== 'path' && (
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'seqKey']} name={['streamSettings', 'xhttpSettings', 'seqKey']}
label={t('pages.inbounds.form.sequenceKey')} label={t('pages.inbounds.form.sequenceKey')}
> >
<Input placeholder="x_seq" /> <Input placeholder="x_seq" />
</Form.Item> </FormField>
)} )}
{xhttpMode === 'packet-up' && ( {xhttpMode === 'packet-up' && (
<> <>
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']} name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
label={t('pages.inbounds.form.uplinkDataPlacement')} label={t('pages.inbounds.form.uplinkDataPlacement')}
> >
@@ -245,83 +259,77 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
{ value: 'query', label: 'query' }, { value: 'query', label: 'query' },
]} ]}
/> />
</Form.Item> </FormField>
{xhttpUplinkPlacement && xhttpUplinkPlacement !== 'body' && ( {xhttpUplinkPlacement && xhttpUplinkPlacement !== 'body' && (
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']} name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
label={t('pages.inbounds.form.uplinkDataKey')} label={t('pages.inbounds.form.uplinkDataKey')}
> >
<Input placeholder="x_data" /> <Input placeholder="x_data" />
</Form.Item> </FormField>
)} )}
</> </>
)} )}
<Form.Item <FormField
name={['streamSettings', 'xhttpSettings', 'noSSEHeader']} name={['streamSettings', 'xhttpSettings', 'noSSEHeader']}
label={t('pages.inbounds.form.noSseHeader')} label={t('pages.inbounds.form.noSseHeader')}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
{/* XMUX is the connection-multiplexing layer {/* XMUX is the connection-multiplexing layer
xHTTP uses to fan out parallel requests over xHTTP uses to fan out parallel requests over
a small pool of upstream connections. UI-only a small pool of upstream connections. UI-only
toggle (enableXmux) hides the 6 nested knobs toggle (enableXmux) hides the 6 nested knobs
when off. */} when off. */}
<Form.Item <FormField
label="XMUX" label="XMUX"
name={['streamSettings', 'xhttpSettings', 'enableXmux']} name={['streamSettings', 'xhttpSettings', 'enableXmux']}
valuePropName="checked" valueProp="checked"
onAfterChange={(v) => onXmuxToggle(v as boolean)}
> >
<Switch onChange={onXmuxToggle} /> <Switch />
</Form.Item> </FormField>
<Form.Item shouldUpdate noStyle> {enableXmux && (
{() => { <>
if (!form.getFieldValue([ <FormField
'streamSettings', 'xhttpSettings', 'enableXmux', label={t('pages.xray.outboundForm.maxConcurrency')}
])) return null; name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
return ( >
<> <Input placeholder="16-32" />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.maxConcurrency')} <FormField
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']} label={t('pages.xray.outboundForm.maxConnections')}
> name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
<Input placeholder="16-32" /> >
</Form.Item> <Input placeholder="0" />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.maxConnections')} <FormField
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']} label={t('pages.xray.outboundForm.maxReuseTimes')}
> name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
<Input placeholder="0" /> >
</Form.Item> <Input />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.maxReuseTimes')} <FormField
name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']} label={t('pages.xray.outboundForm.maxRequestTimes')}
> name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
<Input /> >
</Form.Item> <Input placeholder="600-900" />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.maxRequestTimes')} <FormField
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']} label={t('pages.xray.outboundForm.maxReusableSecs')}
> name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
<Input placeholder="600-900" /> >
</Form.Item> <Input placeholder="1800-3000" />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.maxReusableSecs')} <FormField
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']} label={t('pages.xray.outboundForm.keepAlivePeriod')}
> name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
<Input placeholder="1800-3000" /> >
</Form.Item> <InputNumber min={0} style={{ width: '100%' }} />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.keepAlivePeriod')} </>
name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']} )}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</>
);
}}
</Form.Item>
</> </>
); );
} }
@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next'; 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 type { MessageInstance } from 'antd/es/message/interface';
import { HttpUtil, RandomUtil } from '@/utils'; import { HttpUtil, RandomUtil } from '@/utils';
@@ -10,23 +10,29 @@ import type { InboundFormValues } from '@/schemas/forms/inbound-form';
import type { RealityScanResult } from '@/generated/types'; import type { RealityScanResult } from '@/generated/types';
interface UseSecurityActionsArgs { interface UseSecurityActionsArgs {
form: FormInstance<InboundFormValues>; methods: UseFormReturn<InboundFormValues>;
setSaving: Dispatch<SetStateAction<boolean>>; setSaving: Dispatch<SetStateAction<boolean>>;
messageApi: MessageInstance; 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 — * Node the inbound is deployed to (null = central panel). "Set Cert from
// the central panel's paths don't exist on the node. See issue #4854. * 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; nodeId: number | null;
setScanResult: Dispatch<SetStateAction<RealityScanResult | null>>; setScanResult: Dispatch<SetStateAction<RealityScanResult | null>>;
setScanning: Dispatch<SetStateAction<boolean>>; setScanning: Dispatch<SetStateAction<boolean>>;
} }
// Server-side TLS / Reality key + certificate generation handlers for the /*
// inbound modal's security tab. Each talks to a /panel server endpoint and * Server-side TLS / Reality key + certificate generation handlers for the
// writes the result back into the form. Lifted out of InboundFormModal so * inbound modal's security tab. Each talks to a /panel server endpoint and
// the modal body stays focused on orchestration. * writes the result back into the form. Lifted out of InboundFormModal so
export function useSecurityActions({ form, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) { * the modal body stays focused on orchestration.
*/
export function useSecurityActions({ methods, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
const { t } = useTranslation(); 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 () => { const genRealityKeypair = async () => {
setSaving(true); setSaving(true);
@@ -34,8 +40,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert'); const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
if (msg?.success) { if (msg?.success) {
const obj = msg.obj as { privateKey: string; publicKey: string }; const obj = msg.obj as { privateKey: string; publicKey: string };
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey); setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey); setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
} }
} finally { } finally {
setSaving(false); setSaving(false);
@@ -43,8 +49,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
}; };
const clearRealityKeypair = () => { const clearRealityKeypair = () => {
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], ''); setValue('streamSettings.realitySettings.privateKey', '');
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], ''); setValue('streamSettings.realitySettings.settings.publicKey', '');
}; };
const genMldsa65 = async () => { const genMldsa65 = async () => {
@@ -53,8 +59,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65'); const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
if (msg?.success) { if (msg?.success) {
const obj = msg.obj as { seed: string; verify: string }; const obj = msg.obj as { seed: string; verify: string };
form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], obj.seed); setValue('streamSettings.realitySettings.mldsa65Seed', obj.seed);
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], obj.verify); setValue('streamSettings.realitySettings.settings.mldsa65Verify', obj.verify);
} }
} finally { } finally {
setSaving(false); setSaving(false);
@@ -62,20 +68,20 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
}; };
const clearMldsa65 = () => { const clearMldsa65 = () => {
form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], ''); setValue('streamSettings.realitySettings.mldsa65Seed', '');
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], ''); setValue('streamSettings.realitySettings.settings.mldsa65Verify', '');
}; };
const applyRealityScanResult = (r: RealityScanResult) => { const applyRealityScanResult = (r: RealityScanResult) => {
setScanResult(r); setScanResult(r);
form.setFieldValue(['streamSettings', 'realitySettings', 'target'], r.target); setValue('streamSettings.realitySettings.target', r.target);
if (r.serverNames?.length) { if (r.serverNames?.length) {
form.setFieldValue(['streamSettings', 'realitySettings', 'serverNames'], r.serverNames); setValue('streamSettings.realitySettings.serverNames', r.serverNames);
} }
}; };
const scanRealityTarget = async () => { 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) { if (!target) {
messageApi.warning(t('pages.inbounds.form.realityTargetRequired')); messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
return; return;
@@ -118,28 +124,28 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
}; };
const randomizeShortIds = () => { const randomizeShortIds = () => {
form.setFieldValue( setValue(
['streamSettings', 'realitySettings', 'shortIds'], 'streamSettings.realitySettings.shortIds',
RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean), RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean),
); );
}; };
const randomizeSpiderX = () => { const randomizeSpiderX = () => {
form.setFieldValue( setValue(
['streamSettings', 'realitySettings', 'settings', 'spiderX'], 'streamSettings.realitySettings.settings.spiderX',
`/${RandomUtil.randomSeq(15)}`, `/${RandomUtil.randomSeq(15)}`,
); );
}; };
const getNewEchCert = async () => { const getNewEchCert = async () => {
const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']); const sni = getValues('streamSettings.tlsSettings.serverName');
setSaving(true); setSaving(true);
try { try {
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni }); const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni });
if (msg?.success) { if (msg?.success) {
const obj = msg.obj as { echServerKeys: string; echConfigList: string }; const obj = msg.obj as { echServerKeys: string; echConfigList: string };
form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], obj.echServerKeys); setValue('streamSettings.tlsSettings.echServerKeys', obj.echServerKeys);
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], obj.echConfigList); setValue('streamSettings.tlsSettings.settings.echConfigList', obj.echConfigList);
} }
} finally { } finally {
setSaving(false); setSaving(false);
@@ -147,15 +153,17 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
}; };
const clearEchCert = () => { const clearEchCert = () => {
form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], ''); setValue('streamSettings.tlsSettings.echServerKeys', '');
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], ''); 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 * Fill the pinned-cert field from the inbound's own certificate: read the
// its hex SHA-256, then merge the hash(es) into pinnedPeerCertSha256. * 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 pinFromCert = async () => {
const certs = (form.getFieldValue(['streamSettings', 'tlsSettings', 'certificates']) ?? []) as Array<{ const certs = (getValues('streamSettings.tlsSettings.certificates') ?? []) as Array<{
certificateFile?: string; certificateFile?: string;
certificate?: string[]; certificate?: string[];
}>; }>;
@@ -175,29 +183,33 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
} }
const hashes = (msg.obj as string[] | undefined) ?? []; const hashes = (msg.obj as string[] | undefined) ?? [];
if (hashes.length === 0) return; if (hashes.length === 0) return;
const current = (form.getFieldValue( const current = (getValues(
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], 'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
) as string[] | undefined) ?? []; ) as string[] | undefined) ?? [];
const merged = Array.from(new Set([...current, ...hashes])); const merged = Array.from(new Set([...current, ...hashes]));
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged); setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
} finally { } finally {
setSaving(false); 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 * Fill the pinned-cert field by pinging the configured SNI: fetches the live
// hold the cert file (a CDN front / external endpoint). * 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 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) { if (!server) {
messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni')); messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni'));
return; 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 * `xray tls ping` defaults to :443, but a self-hosted inbound rarely
// carries one) so the ping reaches the actual TLS endpoint. * listens there. Append the inbound's own port (unless the SNI already
const port = form.getFieldValue('port') as number | undefined; * 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}`; const target = /:\d+$/.test(server) || !port ? server : `${server}:${port}`;
setSaving(true); setSaving(true);
try { try {
@@ -208,11 +220,11 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
} }
const hashes = (msg.obj as string[] | undefined) ?? []; const hashes = (msg.obj as string[] | undefined) ?? [];
if (hashes.length === 0) return; if (hashes.length === 0) return;
const current = (form.getFieldValue( const current = (getValues(
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], 'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
) as string[] | undefined) ?? []; ) as string[] | undefined) ?? [];
const merged = Array.from(new Set([...current, ...hashes])); const merged = Array.from(new Set([...current, ...hashes]));
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged); setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -221,8 +233,10 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
const setCertFromPanel = async (certName: number) => { const setCertFromPanel = async (certName: number) => {
setSaving(true); setSaving(true);
try { 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' const msg = typeof nodeId === 'number'
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true }) ? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
: await HttpUtil.post('/panel/api/setting/all', 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')); messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
return; return;
} }
form.setFieldValue( setValue(
['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'], `streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
obj.webCertFile ?? '', obj.webCertFile ?? '',
); );
form.setFieldValue( setValue(
['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'], `streamSettings.tlsSettings.certificates.${certName}.keyFile`,
obj.webKeyFile ?? '', obj.webKeyFile ?? '',
); );
} finally { } finally {
@@ -249,19 +263,19 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
}; };
const clearCertFiles = (certName: number) => { const clearCertFiles = (certName: number) => {
form.setFieldValue( setValue(
['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'], `streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
'', '',
); );
form.setFieldValue( setValue(
['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'], `streamSettings.tlsSettings.certificates.${certName}.keyFile`,
'', '',
); );
}; };
const onSecurityChange = async (next: string) => { const onSecurityChange = async (next: string) => {
setScanResult(null); setScanResult(null);
const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {}; const current = (getValues('streamSettings') as Record<string, unknown>) ?? {};
const cleaned: Record<string, unknown> = { ...current, security: next }; const cleaned: Record<string, unknown> = { ...current, security: next };
delete cleaned.tlsSettings; delete cleaned.tlsSettings;
delete cleaned.realitySettings; 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); reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
cleaned.realitySettings = reality; cleaned.realitySettings = reality;
} }
form.setFieldValue('streamSettings', cleaned); setValue('streamSettings', cleaned);
if (next === 'reality') { if (next === 'reality') {
randomizeSpiderX(); randomizeSpiderX();
try { try {
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert'); const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
if (msg?.success) { if (msg?.success) {
const obj = msg.obj as { privateKey: string; publicKey: string }; const obj = msg.obj as { privateKey: string; publicKey: string };
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey); setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey); setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
} }
} catch { } catch {
// best-effort: leave keypair fields empty if server call fails /* best-effort: leave keypair fields empty if server call fails */
} }
} }
}; };
+57 -54
View File
@@ -22,8 +22,9 @@ import {
UserOutlined, UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { FormProvider, useForm } from 'react-hook-form';
import { HttpUtil, LanguageManager } from '@/utils'; import { HttpUtil, LanguageManager } from '@/utils';
import { antdRule } from '@/utils/zodForm'; import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login'; import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
@@ -48,6 +49,7 @@ export default function LoginPage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [twoFactorEnable, setTwoFactorEnable] = useState(false); const [twoFactorEnable, setTwoFactorEnable] = useState(false);
const [headlineIndex, setHeadlineIndex] = useState(0); const [headlineIndex, setHeadlineIndex] = useState(0);
const methods = useForm<LoginForm>({ defaultValues: { username: '', password: '', twoFactorCode: '' } });
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage()); const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const headlineWords = useMemo( const headlineWords = useMemo(
@@ -180,66 +182,67 @@ export default function LoginPage() {
<b key={headlineIndex}>{headlineWords[headlineIndex]}</b> <b key={headlineIndex}>{headlineWords[headlineIndex]}</b>
</h2> </h2>
<Form <FormProvider {...methods}>
layout="vertical" <Form
className="login-form" layout="vertical"
onFinish={onSubmit} className="login-form"
initialValues={{ username: '', password: '', twoFactorCode: '' }} onFinish={methods.handleSubmit(onSubmit)}
>
<Form.Item
label={t('username')}
name="username"
rules={[antdRule(LoginFormSchema.shape.username, t)]}
> >
<Input <FormField
prefix={<UserOutlined />} name="username"
autoComplete="username" label={t('username')}
size="large" rules={{ validate: rhfZodValidate(LoginFormSchema.shape.username) }}
placeholder={t('username')}
autoFocus
/>
</Form.Item>
<Form.Item
label={t('password')}
name="password"
rules={[antdRule(LoginFormSchema.shape.password, t)]}
>
<Input.Password
prefix={<LockOutlined />}
autoComplete="current-password"
size="large"
placeholder={t('password')}
/>
</Form.Item>
{twoFactorEnable && (
<Form.Item
label={t('twoFactorCode')}
name="twoFactorCode"
rules={[antdRule(TwoFactorCodeSchema, t)]}
> >
<Input <Input
prefix={<KeyOutlined />} prefix={<UserOutlined />}
autoComplete="one-time-code" autoComplete="username"
size="large" size="large"
placeholder={t('twoFactorCode')} placeholder={t('username')}
autoFocus
/> />
</Form.Item> </FormField>
)}
<Form.Item className="submit-row"> <FormField
<Button name="password"
type="primary" label={t('password')}
htmlType="submit" rules={{ validate: rhfZodValidate(LoginFormSchema.shape.password) }}
loading={submitting}
size="large"
block
> >
{t('login')} <Input.Password
</Button> prefix={<LockOutlined />}
</Form.Item> autoComplete="current-password"
</Form> size="large"
placeholder={t('password')}
/>
</FormField>
{twoFactorEnable && (
<FormField
name="twoFactorCode"
label={t('twoFactorCode')}
rules={{ validate: rhfZodValidate(TwoFactorCodeSchema) }}
>
<Input
prefix={<KeyOutlined />}
autoComplete="one-time-code"
size="large"
placeholder={t('twoFactorCode')}
/>
</FormField>
)}
<Form.Item className="submit-row">
<Button
type="primary"
htmlType="submit"
loading={submitting}
size="large"
block
>
{t('login')}
</Button>
</Form.Item>
</Form>
</FormProvider>
</div> </div>
)} )}
</div> </div>
+225 -237
View File
@@ -13,11 +13,12 @@ import {
Switch, Switch,
message, message,
} from 'antd'; } from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { NodeRecord } from '@/api/queries/useNodesQuery';
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations'; import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
import type { Msg } from '@/utils'; import type { Msg } from '@/utils';
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node'; 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 { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import './NodeFormModal.css'; import './NodeFormModal.css';
@@ -65,7 +66,7 @@ export default function NodeFormModal({
onOpenChange, onOpenChange,
}: NodeFormModalProps) { }: NodeFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm<NodeFormValues>(); const methods = useForm<NodeFormValues>({ defaultValues: defaultValues() });
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -74,9 +75,9 @@ export default function NodeFormModal({
const [fetchingInbounds, setFetchingInbounds] = useState(false); const [fetchingInbounds, setFetchingInbounds] = useState(false);
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]); const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
const [testResult, setTestResult] = useState<ProbeResult | null>(null); const [testResult, setTestResult] = useState<ProbeResult | null>(null);
const scheme = Form.useWatch('scheme', form) ?? 'https'; const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify'; const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all'; const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true }); const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector); // Outbounds and balancers share one picker (like the panel-outbound selector);
@@ -109,11 +110,10 @@ export default function NodeFormModal({
} }
: base; : base;
if (next.scheme === 'http') next.tlsVerifyMode = 'skip'; if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
form.resetFields(); methods.reset(next);
form.setFieldsValue(next);
setInboundOptions((next.inboundTags || []).map((tag) => ({ tag }))); setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
setTestResult(null); setTestResult(null);
}, [open, mode, node, form]); }, [open, mode, node, methods]);
const title = useMemo( const title = useMemo(
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')), () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
@@ -141,15 +141,11 @@ export default function NodeFormModal({
} }
async function onTest() { async function onTest() {
try { if (!(await methods.trigger(['address', 'port']))) return;
await form.validateFields(['address', 'port']);
} catch {
return;
}
setTesting(true); setTesting(true);
setTestResult(null); setTestResult(null);
try { try {
const payload = buildPayload(form.getFieldsValue(true)); const payload = buildPayload(methods.getValues());
const msg = await testConnection(payload); const msg = await testConnection(payload);
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
setTestResult(msg.obj); setTestResult(msg.obj);
@@ -162,17 +158,13 @@ export default function NodeFormModal({
} }
async function onFetchPin() { async function onFetchPin() {
try { if (!(await methods.trigger(['address', 'port']))) return;
await form.validateFields(['address', 'port']);
} catch {
return;
}
setFetchingPin(true); setFetchingPin(true);
try { try {
const payload = buildPayload(form.getFieldsValue(true)); const payload = buildPayload(methods.getValues());
const msg = await fetchFingerprint(payload); const msg = await fetchFingerprint(payload);
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
form.setFieldValue('pinnedCertSha256', msg.obj); methods.setValue('pinnedCertSha256', msg.obj);
messageApi.success(t('pages.nodes.pinFetched')); messageApi.success(t('pages.nodes.pinFetched'));
} else { } else {
messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed')); messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
@@ -183,14 +175,10 @@ export default function NodeFormModal({
} }
async function onFetchInbounds() { async function onFetchInbounds() {
try { if (!(await methods.trigger(['name', 'address', 'port', 'apiToken']))) return;
await form.validateFields(['name', 'address', 'port', 'apiToken']);
} catch {
return;
}
setFetchingInbounds(true); setFetchingInbounds(true);
try { try {
const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true))); const msg = await fetchInbounds(buildPayload(methods.getValues()));
if (msg?.success && Array.isArray(msg.obj)) { if (msg?.success && Array.isArray(msg.obj)) {
setInboundOptions(msg.obj); setInboundOptions(msg.obj);
messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length })); messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
@@ -242,229 +230,229 @@ export default function NodeFormModal({
cancelText={t('cancel')} cancelText={t('cancel')}
mask={{ closable: false }} mask={{ closable: false }}
width="640px" width="640px"
onOk={() => form.submit()} onOk={methods.handleSubmit(onFinish)}
onCancel={close} onCancel={close}
> >
<Form <FormProvider {...methods}>
form={form} <Form layout="vertical">
layout="vertical" <Row gutter={16}>
initialValues={defaultValues()} <Col xs={24} md={12}>
onFinish={onFinish} <FormField
> label={t('pages.nodes.name')}
<Row gutter={16}> name="name"
<Col xs={24} md={12}> rules={{ validate: rhfZodValidate(NodeFormSchema.shape.name) }}
<Form.Item >
label={t('pages.nodes.name')} <Input placeholder={t('pages.nodes.namePlaceholder')} />
name="name" </FormField>
rules={[antdRule(NodeFormSchema.shape.name, t)]} </Col>
> <Col xs={24} md={12}>
<Input placeholder={t('pages.nodes.namePlaceholder')} /> <FormField label={t('pages.nodes.remark')} name="remark">
</Form.Item> <Input />
</Col> </FormField>
<Col xs={24} md={12}> </Col>
<Form.Item label={t('pages.nodes.remark')} name="remark"> </Row>
<Input />
</Form.Item>
</Col>
</Row>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} md={6}> <Col xs={24} md={6}>
<Form.Item label={t('pages.nodes.scheme')} name="scheme"> <FormField
<Select label={t('pages.nodes.scheme')}
options={[ name="scheme"
{ value: 'https', label: 'https' }, onAfterChange={(value) => {
{ value: 'http', label: 'http' }, if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
]}
onChange={(value) => {
if (value === 'http') form.setFieldValue('tlsVerifyMode', 'skip');
}} }}
/> >
</Form.Item> <Select
</Col> options={[
<Col xs={24} md={12}> { value: 'https', label: 'https' },
<Form.Item { value: 'http', label: 'http' },
label={t('pages.nodes.address')} ]}
name="address" />
rules={[antdRule(NodeFormSchema.shape.address, t)]} </FormField>
> </Col>
<Input placeholder={t('pages.nodes.addressPlaceholder')} /> <Col xs={24} md={12}>
</Form.Item> <FormField
</Col> label={t('pages.nodes.address')}
<Col xs={24} md={6}> name="address"
<Form.Item rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
label={t('pages.nodes.port')} >
name="port" <Input placeholder={t('pages.nodes.addressPlaceholder')} />
rules={[antdRule(NodeFormSchema.shape.port, t)]} </FormField>
> </Col>
<InputNumber min={1} max={65535} style={{ width: '100%' }} /> <Col xs={24} md={6}>
</Form.Item> <FormField
</Col> label={t('pages.nodes.port')}
</Row> name="port"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</FormField>
</Col>
</Row>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item label={t('pages.nodes.basePath')} name="basePath"> <FormField label={t('pages.nodes.basePath')} name="basePath">
<Input placeholder="/" /> <Input placeholder="/" />
</Form.Item> </FormField>
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item <FormField
label={t('pages.nodes.enable')} label={t('pages.nodes.enable')}
name="enable" name="enable"
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
</Col> </Col>
</Row> </Row>
<Form.Item <FormField
label={t('pages.nodes.allowPrivateAddress')} label={t('pages.nodes.allowPrivateAddress')}
name="allowPrivateAddress" name="allowPrivateAddress"
valuePropName="checked" valueProp="checked"
tooltip={t('pages.nodes.allowPrivateAddressHint')} tooltip={t('pages.nodes.allowPrivateAddressHint')}
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.nodes.tlsVerifyMode')}
name="tlsVerifyMode"
tooltip={t('pages.nodes.tlsVerifyModeHint')}
>
<Select
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') },
]}
/>
</Form.Item>
{tlsVerifyMode === 'skip' && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.tlsSkipWarning')}
/>
)}
{tlsVerifyMode === 'mtls' && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.mtlsFormHint')}
/>
)}
{tlsVerifyMode === 'pin' && (
<Form.Item
label={t('pages.nodes.pinnedCert')}
name="pinnedCertSha256"
tooltip={t('pages.nodes.pinnedCertHint')}
> >
<Input.Search <Switch />
placeholder={t('pages.nodes.pinnedCertPlaceholder')} </FormField>
enterButton={t('pages.nodes.fetchPin')}
loading={fetchingPin}
onSearch={onFetchPin}
/>
</Form.Item>
)}
<Form.Item <FormField
label={t('pages.nodes.apiToken')} label={t('pages.nodes.tlsVerifyMode')}
name="apiToken" name="tlsVerifyMode"
rules={[antdRule(NodeFormSchema.shape.apiToken, t)]} tooltip={t('pages.nodes.tlsVerifyModeHint')}
tooltip={t('pages.nodes.apiTokenHint')}
>
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
</Form.Item>
<Form.Item
label={t('pages.nodes.outboundTag')}
name="outboundTag"
tooltip={t('pages.nodes.outboundTagHint')}
getValueProps={(v) => ({ value: (v as string) || undefined })}
>
<Select
allowClear
showSearch
placeholder={t('pages.nodes.outboundTagPlaceholder')}
options={outboundOptions}
/>
</Form.Item>
<Form.Item
label={t('pages.nodes.inboundSyncMode')}
name="inboundSyncMode"
tooltip={t('pages.nodes.inboundSyncModeHint')}
>
<Select
options={[
{ value: 'all', label: t('pages.nodes.allInbounds') },
{ value: 'selected', label: t('pages.nodes.selectedInbounds') },
]}
/>
</Form.Item>
{inboundSyncMode === 'selected' && (
<Form.Item
label={t('pages.nodes.inboundTags')}
name="inboundTags"
tooltip={t('pages.nodes.inboundTagsHint')}
> >
<Select <Select
mode="multiple" disabled={scheme === 'http'}
allowClear options={[
loading={fetchingInbounds} { value: 'verify', label: t('pages.nodes.tlsVerify') },
placeholder={t('pages.nodes.inboundTagsPlaceholder')} { value: 'pin', label: t('pages.nodes.tlsPin') },
popupRender={(menu) => ( { value: 'skip', label: t('pages.nodes.tlsSkip') },
<> { value: 'mtls', label: t('pages.nodes.tlsMtls') },
<Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}> ]}
{t('pages.nodes.loadInbounds')}
</Button>
{menu}
</>
)}
options={inboundOptions.map((inbound) => ({
value: inbound.tag,
label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
}))}
/> />
</Form.Item> </FormField>
)}
<div className="test-row"> {tlsVerifyMode === 'skip' && (
<Button type="default" loading={testing} onClick={onTest}> <Alert
{t('pages.nodes.testConnection')} type="warning"
</Button> showIcon
{testResult && ( style={{ marginBottom: 16 }}
<div className="test-result"> title={t('pages.nodes.tlsSkipWarning')}
{testResult.status === 'online' ? ( />
<Alert
type="success"
showIcon
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
/>
) : (
<Alert
type="error"
showIcon
title={t('pages.nodes.connectionFailed')}
description={testResult.error}
/>
)}
</div>
)} )}
</div>
</Form> {tlsVerifyMode === 'mtls' && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.mtlsFormHint')}
/>
)}
{tlsVerifyMode === 'pin' && (
<FormField
label={t('pages.nodes.pinnedCert')}
name="pinnedCertSha256"
tooltip={t('pages.nodes.pinnedCertHint')}
>
<Input.Search
placeholder={t('pages.nodes.pinnedCertPlaceholder')}
enterButton={t('pages.nodes.fetchPin')}
loading={fetchingPin}
onSearch={onFetchPin}
/>
</FormField>
)}
<FormField
label={t('pages.nodes.apiToken')}
name="apiToken"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
tooltip={t('pages.nodes.apiTokenHint')}
>
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
</FormField>
<FormField
label={t('pages.nodes.outboundTag')}
name="outboundTag"
tooltip={t('pages.nodes.outboundTagHint')}
transform={{ input: (v) => (v as string) || undefined }}
>
<Select
allowClear
showSearch
placeholder={t('pages.nodes.outboundTagPlaceholder')}
options={outboundOptions}
/>
</FormField>
<FormField
label={t('pages.nodes.inboundSyncMode')}
name="inboundSyncMode"
tooltip={t('pages.nodes.inboundSyncModeHint')}
>
<Select
options={[
{ value: 'all', label: t('pages.nodes.allInbounds') },
{ value: 'selected', label: t('pages.nodes.selectedInbounds') },
]}
/>
</FormField>
{inboundSyncMode === 'selected' && (
<FormField
label={t('pages.nodes.inboundTags')}
name="inboundTags"
tooltip={t('pages.nodes.inboundTagsHint')}
>
<Select
mode="multiple"
allowClear
loading={fetchingInbounds}
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
popupRender={(menu) => (
<>
<Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
{t('pages.nodes.loadInbounds')}
</Button>
{menu}
</>
)}
options={inboundOptions.map((inbound) => ({
value: inbound.tag,
label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
}))}
/>
</FormField>
)}
<div className="test-row">
<Button type="default" loading={testing} onClick={onTest}>
{t('pages.nodes.testConnection')}
</Button>
{testResult && (
<div className="test-result">
{testResult.status === 'online' ? (
<Alert
type="success"
showIcon
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
/>
) : (
<Alert
type="error"
showIcon
title={t('pages.nodes.connectionFailed')}
description={testResult.error}
/>
)}
</div>
)}
</div>
</Form>
</FormProvider>
</Modal> </Modal>
</> </>
); );
@@ -1,16 +1,18 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd'; import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; 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 { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
import { import {
BalancerFormSchema, BalancerFormSchema,
type BalancerFormValues, type BalancerFormValues,
} from '@/schemas/xray'; } from '@/schemas/xray';
import { import {
BalancerStrategyTypeSchema, BalancerStrategyTypeSchema,
type BalancerStrategySettings,
type BalancerStrategyType, type BalancerStrategyType,
} from '@/schemas/routing'; } from '@/schemas/routing';
@@ -37,15 +39,7 @@ const STRATEGIES = BalancerStrategyTypeSchema.options.map((value) => ({
label: STRATEGY_LABELS[value] ?? value, label: STRATEGY_LABELS[value] ?? value,
})); }));
interface FormState { function initialState(balancer: BalancerFormValue | null): BalancerFormValues {
tag: string;
strategy: BalancerStrategyType;
selector: string[];
fallbackTag: string;
settings?: BalancerStrategySettings;
}
function initialState(balancer: BalancerFormValue | null): FormState {
if (!balancer) { if (!balancer) {
return { tag: '', strategy: 'random', selector: [], fallbackTag: '' }; return { tag: '', strategy: 'random', selector: [], fallbackTag: '' };
} }
@@ -67,64 +61,46 @@ export default function BalancerFormModal({
onConfirm, onConfirm,
}: BalancerFormModalProps) { }: BalancerFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [state, setState] = useState<FormState>(() => initialState(balancer)); const methods = useForm<BalancerFormValues>({ defaultValues: initialState(balancer) });
const [touched, setTouched] = useState<Partial<Record<keyof FormState, boolean>>>({});
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const isEdit = balancer != null; const isEdit = balancer != null;
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => { useEffect(() => {
setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true })); if (open) {
setState((prev) => ({ ...prev, [key]: value })); methods.reset(initialState(balancer));
}; setSubmitAttempted(false);
const parsed = useMemo(
() => BalancerFormSchema.safeParse(state),
[state],
);
const duplicateTag = !!state.tag.trim() && otherTags.includes(state.tag.trim());
const issues = useMemo(() => {
const map: Record<string, string> = {};
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 });
}
} }
return map; }, [open, balancer, methods]);
}, [parsed, t]);
const showTagIssue = submitAttempted || !!touched.tag; const strategy = useWatch({ control: methods.control, name: 'strategy' });
const showSelectorIssue = submitAttempted || !!touched.selector; const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? [];
const tagError = showTagIssue ? issues.tag : ''; const costs = useWatch({ control: methods.control, name: 'settings.costs' }) ?? [];
const selectorError = showSelectorIssue ? issues.selector : '';
const showDuplicate = showTagIssue && duplicateTag;
function submit() { 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<string>();
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<BalancerFormValues>, { message: issue.message });
}
}
}
if (!parsed.success || duplicateTag) { if (!parsed.success || duplicateTag) {
setSubmitAttempted(true); setSubmitAttempted(true);
return; return;
} }
const values = { ...parsed.data }; const result: BalancerFormValues = { ...parsed.data };
if (values.strategy !== 'leastLoad') delete values.settings; if (result.strategy !== 'leastLoad') delete result.settings;
onConfirm(values); onConfirm(result);
} }
const settings = state.settings;
const updateSetting = <K extends keyof BalancerStrategySettings>(
key: K,
value: BalancerStrategySettings[K],
) => {
setState((prev) => ({
...prev,
settings: { ...(prev.settings ?? {}), [key]: value },
}));
};
const updateBaselines = (next: string[]) => updateSetting('baselines', next);
const updateCosts = (next: NonNullable<BalancerStrategySettings['costs']>) => updateSetting('costs', next);
const baselines = settings?.baselines ?? [];
const costs = settings?.costs ?? [];
const fallbackOptions = useMemo( const fallbackOptions = useMemo(
() => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })), () => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })),
[outboundTags, t], [outboundTags, t],
@@ -145,141 +121,144 @@ export default function BalancerFormModal({
onOk={submit} onOk={submit}
onCancel={onClose} onCancel={onClose}
> >
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}> <FormProvider {...methods}>
<Form.Item <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
label={t('pages.xray.balancer.tag')} <Controller
required control={methods.control}
validateStatus={tagError ? 'error' : showDuplicate ? 'warning' : ''} name="tag"
help={tagError || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')} render={({ field, fieldState }) => {
hasFeedback const trimmed = (field.value ?? '').trim();
> const duplicate = !!trimmed && otherTags.includes(trimmed);
<Input const errorMessage = fieldState.error?.message
value={state.tag} ? t(fieldState.error.message, { defaultValue: fieldState.error.message })
onChange={(e) => update('tag', e.target.value)} : '';
placeholder={t('pages.xray.balancer.tagPlaceholder')} const showDuplicate = !errorMessage && (submitAttempted || fieldState.isTouched) && duplicate;
return (
<Form.Item
label={t('pages.xray.balancer.tag')}
required
validateStatus={errorMessage ? 'error' : showDuplicate ? 'warning' : ''}
help={errorMessage || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
hasFeedback
>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
ref={field.ref}
placeholder={t('pages.xray.balancer.tagPlaceholder')}
/>
</Form.Item>
);
}}
/> />
</Form.Item> <FormField name="strategy" label={t('pages.xray.balancer.balancerStrategy')}>
<Form.Item label={t('pages.xray.balancer.balancerStrategy')}> <Select options={STRATEGIES} />
<Select </FormField>
value={state.strategy} <FormField name="selector" label={t('pages.xray.balancer.selector')} required>
onChange={(v) => update('strategy', v)} <Select
options={STRATEGIES} mode="tags"
/> tokenSeparators={[',']}
</Form.Item> options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
<Form.Item />
label={t('pages.xray.balancer.selector')} </FormField>
required <FormField
validateStatus={selectorError ? 'error' : ''} name="fallbackTag"
help={selectorError || ''} label={t('pages.xray.balancer.fallback')}
hasFeedback transform={{ output: (v) => v ?? '' }}
> >
<Select <Select allowClear options={fallbackOptions} />
mode="tags" </FormField>
value={state.selector}
onChange={(v) => update('selector', v)}
tokenSeparators={[',']}
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
/>
</Form.Item>
<Form.Item label={t('pages.xray.balancer.fallback')}>
<Select
value={state.fallbackTag}
onChange={(v) => update('fallbackTag', v ?? '')}
allowClear
options={fallbackOptions}
/>
</Form.Item>
{state.strategy === 'leastLoad' && ( {strategy === 'leastLoad' && (
<> <>
<Form.Item label={t('pages.xray.balancer.expected')}> <FormField
<InputNumber name={['settings', 'expected']}
value={settings?.expected} label={t('pages.xray.balancer.expected')}
onChange={(v) => updateSetting('expected', typeof v === 'number' ? v : undefined)} transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
min={0} >
placeholder={t('pages.xray.balancer.expectedPlaceholder')} <InputNumber
style={{ width: '100%' }} min={0}
/> placeholder={t('pages.xray.balancer.expectedPlaceholder')}
</Form.Item> style={{ width: '100%' }}
<Form.Item label={t('pages.xray.balancer.maxRtt')}> />
<Input </FormField>
value={settings?.maxRTT ?? ''} <FormField
onChange={(e) => updateSetting('maxRTT', e.target.value || undefined)} name={['settings', 'maxRTT']}
placeholder="e.g. 1s" label={t('pages.xray.balancer.maxRtt')}
/> transform={{ input: (v) => v ?? '', output: (v) => (typeof v === 'string' && v ? v : undefined) }}
</Form.Item> >
<Form.Item label={t('pages.xray.balancer.tolerance')}> <Input placeholder="e.g. 1s" />
<InputNumber </FormField>
value={settings?.tolerance} <FormField
onChange={(v) => updateSetting('tolerance', typeof v === 'number' ? v : undefined)} name={['settings', 'tolerance']}
min={0} label={t('pages.xray.balancer.tolerance')}
max={1} transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
step={0.01} >
placeholder="0.01 = 1%" <InputNumber min={0} max={1} step={0.01} placeholder="0.01 = 1%" style={{ width: '100%' }} />
style={{ width: '100%' }} </FormField>
/> <Form.Item label={t('pages.xray.balancer.baselines')}>
</Form.Item> <Button
<Form.Item label={t('pages.xray.balancer.baselines')}> size="small"
<Button type="primary"
size="small" icon={<PlusOutlined />}
type="primary" aria-label={t('add')}
icon={<PlusOutlined />} onClick={() => methods.setValue('settings.baselines', [...baselines, ''])}
aria-label={t('add')} />
onClick={() => updateBaselines([...baselines, ''])} {baselines.map((b, idx) => (
/> <Space.Compact key={idx} block style={{ marginTop: 4 }}>
{baselines.map((b, idx) => ( <Input
<Space.Compact key={idx} block style={{ marginTop: 4 }}> value={b}
<Input aria-label={t('pages.xray.balancer.baselines')}
value={b} placeholder="e.g. 1s"
aria-label={t('pages.xray.balancer.baselines')} onChange={(e) => methods.setValue('settings.baselines', baselines.map((x, i) => (i === idx ? e.target.value : x)))}
placeholder="e.g. 1s" />
onChange={(e) => updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))} <InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('settings.baselines', baselines.filter((_, i) => i !== idx))}>
/> <MinusOutlined />
<InputAddon ariaLabel={t('remove')} onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}> </InputAddon>
<MinusOutlined /> </Space.Compact>
</InputAddon> ))}
</Space.Compact> </Form.Item>
))} <Form.Item label={t('pages.xray.balancer.costs')}>
</Form.Item> <Button
<Form.Item label={t('pages.xray.balancer.costs')}> size="small"
<Button type="primary"
size="small" icon={<PlusOutlined />}
type="primary" aria-label={t('add')}
icon={<PlusOutlined />} onClick={() => methods.setValue('settings.costs', [...costs, { regexp: false, match: '', value: 1 }])}
aria-label={t('add')} />
onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])} {costs.map((c, idx) => (
/> <Space.Compact key={idx} block style={{ marginTop: 4 }}>
{costs.map((c, idx) => ( <Switch
<Space.Compact key={idx} block style={{ marginTop: 4 }}> checked={c.regexp}
<Switch aria-label={t('pages.xray.balancer.costRegexp')}
checked={c.regexp} checkedChildren="re"
aria-label={t('pages.xray.balancer.costRegexp')} unCheckedChildren="lit"
checkedChildren="re" onChange={(v) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
unCheckedChildren="lit" />
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))} <Input
/> value={c.match}
<Input aria-label={t('pages.xray.balancer.costMatch')}
value={c.match} placeholder="tag pattern"
aria-label={t('pages.xray.balancer.costMatch')} onChange={(e) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
placeholder="tag pattern" />
onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))} <InputNumber
/> value={c.value}
<InputNumber aria-label={t('pages.xray.balancer.costValue')}
value={c.value} placeholder="weight"
aria-label={t('pages.xray.balancer.costValue')} style={{ width: 100 }}
placeholder="weight" onChange={(v) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
style={{ width: 100 }} />
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))} <InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('settings.costs', costs.filter((_, i) => i !== idx))}>
/> <MinusOutlined />
<InputAddon ariaLabel={t('remove')} onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}> </InputAddon>
<MinusOutlined /> </Space.Compact>
</InputAddon> ))}
</Space.Compact> </Form.Item>
))} </>
</Form.Item> )}
</> </Form>
)} </FormProvider>
</Form>
</Modal> </Modal>
); );
} }
+105 -118
View File
@@ -2,15 +2,16 @@ import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd'; import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { InputAddon } from '@/components/ui'; import { InputAddon } from '@/components/ui';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { import {
DnsQueryStrategySchema, DnsQueryStrategySchema,
DnsServerObjectInnerSchema, DnsServerObjectInnerSchema,
DnsServerObjectSchema, DnsServerObjectSchema,
type DnsServerObject, type DnsServerObject,
} from '@/schemas/dns'; } from '@/schemas/dns';
import { antdRule } from '@/utils/zodForm';
export type DnsServerValue = export type DnsServerValue =
| string | string
@@ -135,17 +136,15 @@ export default function DnsServerModal({
onConfirm, onConfirm,
}: DnsServerModalProps) { }: DnsServerModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm<DnsServerForm>(); const methods = useForm<DnsServerForm>({ 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
form.setFieldsValue(valuesFromServer(server)); methods.reset(valuesFromServer(server));
}, [open, server, form]); }, [open, server, methods]);
async function submit() {
const values = await form.validateFields();
onConfirm(valuesToWire(values));
}
const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add'); const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
@@ -156,124 +155,112 @@ export default function DnsServerModal({
okText={t('confirm')} okText={t('confirm')}
cancelText={t('close')} cancelText={t('close')}
mask={{ closable: false }} mask={{ closable: false }}
onOk={submit} onOk={methods.handleSubmit((values) => onConfirm(valuesToWire(values)))}
onCancel={onClose} onCancel={onClose}
> >
<Form <FormProvider {...methods}>
form={form} <Form
colon={false} colon={false}
labelCol={{ md: { span: 8 } }} labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }} wrapperCol={{ md: { span: 14 } }}
initialValues={defaultFormValues()}
>
<Form.Item
label={t('pages.inbounds.address')}
name="address"
rules={[antdRule(shape.address, t)]}
> >
<Input /> <FormField
</Form.Item> label={t('pages.inbounds.address')}
<Form.Item name="address"
label={t('pages.inbounds.port')} rules={{ validate: rhfZodValidate(shape.address) }}
name="port" >
rules={[antdRule(shape.port, t)]} <Input />
> </FormField>
<InputNumber min={1} max={65535} /> <FormField
</Form.Item> label={t('pages.inbounds.port')}
<Form.Item label={t('pages.xray.dns.tag')} name="tag"> name="port"
<Input /> rules={{ validate: rhfZodValidate(shape.port) }}
</Form.Item> >
<Form.Item label={t('pages.xray.dns.clientIp')} name="clientIP"> <InputNumber min={1} max={65535} />
<Input /> </FormField>
</Form.Item> <FormField label={t('pages.xray.dns.tag')} name="tag">
<Form.Item label={t('pages.xray.dns.strategy')} name="queryStrategy"> <Input />
<Select </FormField>
style={{ width: '100%' }} <FormField label={t('pages.xray.dns.clientIp')} name="clientIP">
options={STRATEGIES.map((s) => ({ value: s, label: s }))} <Input />
/> </FormField>
</Form.Item> <FormField label={t('pages.xray.dns.strategy')} name="queryStrategy">
<Form.Item <Select
label={t('pages.xray.dns.timeoutMs')} style={{ width: '100%' }}
name="timeoutMs" options={STRATEGIES.map((s) => ({ value: s, label: s }))}
rules={[antdRule(shape.timeoutMs, t)]} />
> </FormField>
<InputNumber min={0} step={500} /> <FormField
</Form.Item> label={t('pages.xray.dns.timeoutMs')}
name="timeoutMs"
rules={{ validate: rhfZodValidate(shape.timeoutMs) }}
>
<InputNumber min={0} step={500} />
</FormField>
<Divider style={{ margin: '5px 0' }} /> <Divider style={{ margin: '5px 0' }} />
<Form.List name="domains"> <Form.Item label={t('pages.xray.dns.domains')}>
{(fields, { add, remove }) => ( <Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('domains', [...domains, ''])} />
<Form.Item label={t('pages.xray.dns.domains')}> {domains.map((_, i) => (
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} /> <Space.Compact key={i} block style={{ marginTop: 4 }}>
{fields.map((field) => ( <FormField name={`domains.${i}`} noStyle>
<Space.Compact key={field.key} block style={{ marginTop: 4 }}> <Input />
<Form.Item name={field.name} noStyle> </FormField>
<Input /> <InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('domains', domains.filter((__, idx) => idx !== i))}>
</Form.Item> <MinusOutlined />
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}> </InputAddon>
<MinusOutlined /> </Space.Compact>
</InputAddon> ))}
</Space.Compact> </Form.Item>
))}
</Form.Item>
)}
</Form.List>
<Form.List name="expectedIPs"> <Form.Item label={t('pages.xray.dns.expectIPs')}>
{(fields, { add, remove }) => ( <Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])} />
<Form.Item label={t('pages.xray.dns.expectIPs')}> {expectedIPs.map((_, i) => (
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} /> <Space.Compact key={i} block style={{ marginTop: 4 }}>
{fields.map((field) => ( <FormField name={`expectedIPs.${i}`} noStyle>
<Space.Compact key={field.key} block style={{ marginTop: 4 }}> <Input />
<Form.Item name={field.name} noStyle> </FormField>
<Input /> <InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('expectedIPs', expectedIPs.filter((__, idx) => idx !== i))}>
</Form.Item> <MinusOutlined />
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}> </InputAddon>
<MinusOutlined /> </Space.Compact>
</InputAddon> ))}
</Space.Compact> </Form.Item>
))}
</Form.Item>
)}
</Form.List>
<Form.List name="unexpectedIPs"> <Form.Item label={t('pages.xray.dns.unexpectIPs')}>
{(fields, { add, remove }) => ( <Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])} />
<Form.Item label={t('pages.xray.dns.unexpectIPs')}> {unexpectedIPs.map((_, i) => (
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} /> <Space.Compact key={i} block style={{ marginTop: 4 }}>
{fields.map((field) => ( <FormField name={`unexpectedIPs.${i}`} noStyle>
<Space.Compact key={field.key} block style={{ marginTop: 4 }}> <Input />
<Form.Item name={field.name} noStyle> </FormField>
<Input /> <InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('unexpectedIPs', unexpectedIPs.filter((__, idx) => idx !== i))}>
</Form.Item> <MinusOutlined />
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}> </InputAddon>
<MinusOutlined /> </Space.Compact>
</InputAddon> ))}
</Space.Compact> </Form.Item>
))}
</Form.Item>
)}
</Form.List>
<Divider style={{ margin: '5px 0' }} /> <Divider style={{ margin: '5px 0' }} />
<Form.Item label={t('pages.xray.dns.skipFallback')} name="skipFallback" valuePropName="checked"> <FormField label={t('pages.xray.dns.skipFallback')} name="skipFallback" valueProp="checked">
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.dns.finalQuery')} name="finalQuery" valuePropName="checked"> <FormField label={t('pages.xray.dns.finalQuery')} name="finalQuery" valueProp="checked">
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.dns.disableCache')} name="disableCache" valuePropName="checked"> <FormField label={t('pages.xray.dns.disableCache')} name="disableCache" valueProp="checked">
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.dns.serveStale')} name="serveStale" valuePropName="checked"> <FormField label={t('pages.xray.dns.serveStale')} name="serveStale" valueProp="checked">
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL"> <FormField label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
<InputNumber min={0} step={60} /> <InputNumber min={0} step={60} />
</Form.Item> </FormField>
</Form> </Form>
</FormProvider>
</Modal> </Modal>
); );
} }
@@ -11,8 +11,9 @@ import {
Tabs, Tabs,
message, message,
} from 'antd'; } from 'antd';
import { FinalMaskForm } from '@/lib/xray/forms/transport'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import SniffingFields from '@/lib/xray/forms/SniffingFields'; import { FinalMaskField, SniffingField } from '@/lib/xray/forms/fields';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { JsonEditor } from '@/components/form'; import { JsonEditor } from '@/components/form';
import { Wireguard } from '@/utils'; import { Wireguard } from '@/utils';
import { import {
@@ -31,7 +32,6 @@ import {
canEnableTls, canEnableTls,
canEnableTlsFlow, canEnableTlsFlow,
} from '@/lib/xray/protocol-capabilities'; } from '@/lib/xray/protocol-capabilities';
import { antdRule } from '@/utils/zodForm';
import { import {
FLOW_OPTIONS, FLOW_OPTIONS,
@@ -75,10 +75,7 @@ import {
import { RealityForm, TlsForm } from './security'; import { RealityForm, TlsForm } from './security';
import './OutboundFormModal.css'; import './OutboundFormModal.css';
// Pattern A rewrite of OutboundFormModal. Built as a sibling `.new.tsx` type StreamValue = OutboundFormValues['streamSettings'];
// 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).
interface OutboundFormModalProps { interface OutboundFormModalProps {
open: boolean; open: boolean;
@@ -89,7 +86,6 @@ interface OutboundFormModalProps {
onConfirm: (outbound: Record<string, unknown>) => void; onConfirm: (outbound: Record<string, unknown>) => void;
} }
export default function OutboundFormModal({ export default function OutboundFormModal({
open, open,
outbound: outboundProp, outbound: outboundProp,
@@ -100,15 +96,36 @@ export default function OutboundFormModal({
}: OutboundFormModalProps) { }: OutboundFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [form] = Form.useForm<OutboundFormValues>(); const methods = useForm<OutboundFormValues>({ defaultValues: buildAddModeValues() });
const [activeKey, setActiveKey] = useState('1'); const [activeKey, setActiveKey] = useState('1');
const [jsonText, setJsonText] = useState(''); const [jsonText, setJsonText] = useState('');
const [jsonDirty, setJsonDirty] = useState(false); const [jsonDirty, setJsonDirty] = useState(false);
const [linkInput, setLinkInput] = useState(''); const [linkInput, setLinkInput] = useState('');
// Parse a share link (vmess:// / vless:// / trojan:// / ss:// / const isEdit = outboundProp != null;
// hysteria2:// / wireguard://) and replace form state with the result. const title = isEdit
// The current tag is preserved when the parsed link doesn't carry one. ? `${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() { function importLink() {
const link = linkInput.trim(); const link = linkInput.trim();
if (!link) return; if (!link) return;
@@ -117,11 +134,10 @@ export default function OutboundFormModal({
messageApi.error('Wrong Link!'); messageApi.error('Wrong Link!');
return; return;
} }
const currentTag = form.getFieldValue('tag') as string | undefined; const currentTag = methods.getValues('tag');
if (!parsed.tag && currentTag) parsed.tag = currentTag; if (!parsed.tag && currentTag) parsed.tag = currentTag;
const next = rawOutboundToFormValues(parsed); const next = rawOutboundToFormValues(parsed);
form.resetFields(); methods.reset(next);
form.setFieldsValue(next);
setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2)); setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
setJsonDirty(false); setJsonDirty(false);
setLinkInput(''); setLinkInput('');
@@ -129,89 +145,76 @@ export default function OutboundFormModal({
switchTab('1'); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
const initial = outboundProp const initial = outboundProp
? rawOutboundToFormValues(outboundProp) ? rawOutboundToFormValues(outboundProp)
: buildAddModeValues(); : buildAddModeValues();
form.resetFields(); methods.reset(initial);
form.setFieldsValue(initial);
setActiveKey('1'); setActiveKey('1');
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2)); setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
setJsonDirty(false); setJsonDirty(false);
}, [open, outboundProp, form]); }, [open, outboundProp, methods]);
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 } });
useEffect(() => { useEffect(() => {
if (!streamAllowed) return; 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 * Wireguard dials its own UDP — only finalmask/sockopt apply, never a
// protocol switch) so the transmission/security blocks stay hidden. * 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 (protocol === 'wireguard') {
if (network) form.setFieldValue('streamSettings', { security: 'none' }); if (network) methods.setValue('streamSettings', { security: 'none' } as StreamValue);
return; return;
} }
if (network) return; if (network) return;
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' }); methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue);
// eslint-disable-next-line react-hooks/exhaustive-deps }, [streamAllowed, network, protocol, methods]);
}, [streamAllowed, network, protocol]);
useEffect(() => { useEffect(() => {
if (protocol !== 'hysteria') return; if (protocol !== 'hysteria') return;
if (network === 'hysteria' && security === 'tls') return; if (network === 'hysteria' && security === 'tls') return;
const existing = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>; const existing = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
const slice = hysteriaStreamSlice(); const slice = hysteriaStreamSlice();
if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings; if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings; if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
form.setFieldValue('streamSettings', slice); methods.setValue('streamSettings', slice as StreamValue);
}, [protocol, network, security, form]); }, [protocol, network, security, methods]);
const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
useEffect(() => { useEffect(() => {
if (protocol !== 'wireguard') return; if (protocol !== 'wireguard') return;
const sk = (wgSecretKey ?? '').trim(); const sk = (wgSecretKey ?? '').trim();
if (!sk) { if (!sk) {
form.setFieldValue(['settings', 'pubKey'], ''); methods.setValue('settings.pubKey', '');
return; return;
} }
try { try {
const { publicKey } = Wireguard.generateKeypair(sk); const { publicKey } = Wireguard.generateKeypair(sk);
form.setFieldValue(['settings', 'pubKey'], publicKey); methods.setValue('settings.pubKey', publicKey);
} catch { } catch {
form.setFieldValue(['settings', 'pubKey'], ''); methods.setValue('settings.pubKey', '');
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [protocol, wgSecretKey, methods]);
}, [protocol, wgSecretKey]);
function onValuesChange(changed: Partial<OutboundFormValues>) { useEffect(() => {
if ('protocol' in changed && changed.protocol) { /* eslint-disable-next-line react-hooks/incompatible-library */
const next = rawOutboundToFormValues({ protocol: changed.protocol }); const sub = methods.watch((_value, { name, type }) => {
form.setFieldValue('settings', next.settings); if (name !== 'protocol' || type !== 'change') return;
if (changed.protocol === 'hysteria') { const nextProtocol = methods.getValues('protocol');
form.setFieldValue('streamSettings', hysteriaStreamSlice()); const next = rawOutboundToFormValues({ protocol: nextProtocol });
} else if ((form.getFieldValue(['streamSettings', 'network']) ?? '') === 'hysteria') { methods.setValue('settings', next.settings);
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' }); 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) { function onSecurityChange(next: string) {
const stream = form.getFieldValue('streamSettings') ?? {}; const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
const cleaned = { ...stream } as Record<string, unknown>; const cleaned = { ...stream };
delete cleaned.tlsSettings; delete cleaned.tlsSettings;
delete cleaned.realitySettings; delete cleaned.realitySettings;
if (next === 'tls') { if (next === 'tls') {
@@ -234,23 +237,25 @@ export default function OutboundFormModal({
}; };
} }
cleaned.security = next; 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 * Network change cascade: swap the per-network sub-key (tcpSettings,
// the new network supports it, otherwise force back to 'none'. * wsSettings, etc.) so the DU branch matches. Preserve security if
* the new network supports it, otherwise force back to 'none'.
*/
function onNetworkChange(next: string) { function onNetworkChange(next: string) {
const stream = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>; const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
form.setFieldValue('streamSettings', applyNetworkChange(protocol, stream, next)); methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue);
} }
function onXmuxToggle(checked: boolean) { function onXmuxToggle(checked: boolean) {
if (!checked) return; 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; const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
if (hasValues) return; if (hasValues) return;
form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS }); methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
} }
const duplicateTag = useMemo(() => { const duplicateTag = useMemo(() => {
@@ -260,9 +265,11 @@ export default function OutboundFormModal({
return (existingTags || []).includes(myTag); return (existingTags || []).includes(myTag);
}, [tag, existingTags, isEdit, outboundProp]); }, [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 * Bridge form <-> JSON tab: when leaving the JSON tab back to Basic, push
// form values so the user sees the live shape. * any edits into form state. When entering JSON tab, snapshot current
* form values so the user sees the live shape.
*/
function applyJsonToForm(): boolean { function applyJsonToForm(): boolean {
if (!jsonDirty) return true; if (!jsonDirty) return true;
const raw = jsonText.trim(); const raw = jsonText.trim();
@@ -275,8 +282,7 @@ export default function OutboundFormModal({
return false; return false;
} }
const next = rawOutboundToFormValues(parsed); const next = rawOutboundToFormValues(parsed);
form.resetFields(); methods.reset(next);
form.setFieldsValue(next);
setJsonDirty(false); setJsonDirty(false);
return true; return true;
} }
@@ -290,7 +296,7 @@ export default function OutboundFormModal({
function onTabChange(key: string) { function onTabChange(key: string) {
if (key === '2') { if (key === '2') {
const values = form.getFieldsValue(true) as OutboundFormValues; const values = methods.getValues();
setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2)); setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
setJsonDirty(false); setJsonDirty(false);
switchTab(key); switchTab(key);
@@ -315,16 +321,11 @@ export default function OutboundFormModal({
return; return;
} }
values = rawOutboundToFormValues(parsed); values = rawOutboundToFormValues(parsed);
form.resetFields(); methods.reset(values);
form.setFieldsValue(values);
setJsonDirty(false); setJsonDirty(false);
} else { } else {
try { if (!(await methods.trigger())) return;
await form.validateFields(); values = methods.getValues();
} catch {
return;
}
values = form.getFieldsValue(true) as OutboundFormValues;
} }
const tagValue = (values.tag ?? '').trim(); const tagValue = (values.tag ?? '').trim();
if (!tagValue) { if (!tagValue) {
@@ -354,218 +355,226 @@ export default function OutboundFormModal({
onCancel={onClose} onCancel={onClose}
destroyOnHidden destroyOnHidden
> >
<Form <FormProvider {...methods}>
form={form} <Form
colon={false} colon={false}
labelCol={{ md: { span: 8 } }} labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }} wrapperCol={{ md: { span: 14 } }}
labelWrap labelWrap
onValuesChange={onValuesChange} >
> <Tabs
<Tabs activeKey={activeKey}
activeKey={activeKey} onChange={onTabChange}
onChange={onTabChange} items={[
items={[ {
{ key: '1',
key: '1', label: t('pages.xray.basicTemplate'),
label: t('pages.xray.basicTemplate'), children: (
children: ( <>
<> <FormField
<Form.Item label={t('protocol')}
label={t('protocol')} name="protocol"
name="protocol" rules={{ validate: rhfZodValidate(OutboundFormBaseSchema.shape.tag) }}
rules={[antdRule(OutboundFormBaseSchema.shape.tag, t)]} >
> <Select id="protocol" options={PROTOCOL_OPTIONS} />
<Select options={PROTOCOL_OPTIONS} /> </FormField>
</Form.Item>
<Form.Item <Controller
label={t('pages.xray.outbound.tag')} control={methods.control}
name="tag" name="tag"
validateStatus={duplicateTag ? 'warning' : undefined} rules={{ required: 'pages.xray.outboundForm.tagRequired' }}
help={duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined} render={({ field, fieldState }) => {
rules={[ const errorMessage = fieldState.error?.message
{ required: true, message: t('pages.xray.outboundForm.tagRequired') }, ? t(fieldState.error.message, { defaultValue: fieldState.error.message })
]} : '';
>
<Input placeholder={t('pages.xray.outboundForm.tagPlaceholder')} />
</Form.Item>
<Form.Item label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
</Form.Item>
<Form.Item
label={t('pages.xray.outbound.targetStrategy')}
name="targetStrategy"
tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
>
<Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
</Form.Item>
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
{protocol === 'vmess' && <VmessFields />}
{protocol === 'vless' && <VlessFields />}
{protocol === 'trojan' && <TrojanFields />}
{protocol === 'shadowsocks' && <ShadowsocksFields />}
{protocol === 'http' && <HttpFields />}
{protocol === 'socks' && <SocksFields />}
{protocol === 'loopback' && <LoopbackFields />}
{protocol === 'blackhole' && <BlackholeFields />}
{protocol === 'dns' && <DnsFields />}
{protocol === 'freedom' && <FreedomFields form={form} />}
{protocol === 'vless' && (
<Form.Item shouldUpdate noStyle>
{() => {
const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
if (!reverseTag) return null;
return ( return (
<SniffingFields <Form.Item
name={['settings', 'reverseSniffing']} label={t('pages.xray.outbound.tag')}
form={form} required
enableLabel={t('pages.xray.outboundForm.reverseSniffing')} validateStatus={errorMessage ? 'error' : duplicateTag ? 'warning' : undefined}
/> help={errorMessage || (duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined)}
>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
ref={field.ref}
placeholder={t('pages.xray.outboundForm.tagPlaceholder')}
/>
</Form.Item>
); );
}} }}
</Form.Item> />
)}
{protocol === 'wireguard' && <WireguardFields form={form} />} <FormField label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
</FormField>
{streamAllowed && network && ( <FormField
<> label={t('pages.xray.outbound.targetStrategy')}
<Form.Item name="targetStrategy"
label={t('transmission')} tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
name={['streamSettings', 'network']} >
> <Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
<Select </FormField>
value={network}
onChange={onNetworkChange}
options={
protocol === 'hysteria'
? [HYSTERIA_NETWORK_OPTION]
: NETWORK_OPTIONS
}
/>
</Form.Item>
{network === 'tcp' && <RawForm form={form} />} {SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
{protocol === 'vmess' && <VmessFields />}
{protocol === 'vless' && <VlessFields />}
{protocol === 'trojan' && <TrojanFields />}
{protocol === 'shadowsocks' && <ShadowsocksFields />}
{protocol === 'http' && <HttpFields />}
{protocol === 'socks' && <SocksFields />}
{network === 'kcp' && <KcpForm />} {protocol === 'loopback' && <LoopbackFields />}
{protocol === 'blackhole' && <BlackholeFields />}
{protocol === 'dns' && <DnsFields />}
{network === 'ws' && <WsForm />} {protocol === 'freedom' && <FreedomFields />}
{network === 'grpc' && <GrpcForm />} {protocol === 'vless' && reverseTag && (
<Controller
{network === 'httpupgrade' && <HttpUpgradeForm />} control={methods.control}
name="settings.reverseSniffing"
{network === 'xhttp' && <XhttpForm form={form} onXmuxToggle={onXmuxToggle} />} render={({ field }) => (
<SniffingField
{network === 'hysteria' && <HysteriaForm form={form} />} value={field.value}
</> onChange={field.onChange}
)} enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
/>
{tlsFlowAllowed && ( )}
<Form.Item label={t('pages.clients.flow')} name={['settings', 'flow']}>
<Select
allowClear
placeholder={t('none')}
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
/> />
</Form.Item> )}
)}
{/* Vision seed knobs only meaningful for the exact {protocol === 'wireguard' && <WireguardFields />}
xtls-rprx-vision flow, on TCP+(tls|reality). The
legacy class gated this on `canEnableVisionSeed()`
— same condition encoded inline here. */}
<Form.Item shouldUpdate noStyle>
{() => {
const flow =
(form.getFieldValue(['settings', 'flow']) ?? '') as string;
if (!(tlsFlowAllowed && flow === 'xtls-rprx-vision')) return null;
return (
<>
<Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
<Space.Compact block>
{[900, 500, 900, 256].map((def, i) => (
<Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
<InputNumber min={1} style={{ width: '25%' }} />
</Form.Item>
))}
</Space.Compact>
</Form.Item>
</>
);
}}
</Form.Item>
{streamAllowed && network && ( {streamAllowed && network && (
<Form.Item label={t('security')}> <>
<Radio.Group <Form.Item label={t('transmission')}>
value={security} <Select
buttonStyle="solid" value={network}
onChange={(e) => onSecurityChange(e.target.value as string)} onChange={onNetworkChange}
> options={
{network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>} protocol === 'hysteria'
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>} ? [HYSTERIA_NETWORK_OPTION]
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>} : NETWORK_OPTIONS
</Radio.Group> }
</Form.Item> />
)} </Form.Item>
{security === 'tls' && tlsAllowed && <TlsForm />} {network === 'tcp' && <RawForm />}
{security === 'reality' && realityAllowed && <RealityForm />} {network === 'kcp' && <KcpForm />}
{((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && ( {network === 'ws' && <WsForm />}
<SockoptForm form={form} outboundTags={dialerProxyTags ?? existingTags} />
)}
<FinalMaskForm {network === 'grpc' && <GrpcForm />}
name={['streamSettings', 'finalmask']}
network={network}
protocol={protocol}
form={form}
/>
<MuxForm form={form} protocol={protocol} network={network} /> {network === 'httpupgrade' && <HttpUpgradeForm />}
</>
), {network === 'xhttp' && <XhttpForm onXmuxToggle={onXmuxToggle} />}
},
{ {network === 'hysteria' && <HysteriaForm />}
key: '2', </>
label: 'JSON', )}
children: (
<Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}> {tlsFlowAllowed && (
<Input.Search <FormField label={t('pages.clients.flow')} name={['settings', 'flow']}>
value={linkInput} <Select
placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://" allowClear
enterButton="Import" placeholder={t('none')}
onChange={(e) => setLinkInput(e.target.value)} options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
onSearch={importLink} />
/> </FormField>
<JsonEditor )}
value={jsonText}
onChange={(next) => { {/* Vision seed knobs only meaningful for the exact
setJsonText(next); xtls-rprx-vision flow, on TCP+(tls|reality). */}
setJsonDirty(true); {tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
}} <>
minHeight="360px" <FormField label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
maxHeight="600px" <InputNumber min={0} style={{ width: '100%' }} />
/> </FormField>
</Space> <Form.Item label={t('pages.inbounds.form.visionTestseed')}>
), <Space.Compact block>
}, {[0, 1, 2, 3].map((i) => (
]} <FormField key={i} name={['settings', 'testseed', i]} noStyle>
/> <InputNumber min={1} style={{ width: '25%' }} />
</Form> </FormField>
))}
</Space.Compact>
</Form.Item>
</>
)}
{streamAllowed && network && (
<Form.Item label={t('security')}>
<Radio.Group
value={security}
buttonStyle="solid"
onChange={(e) => onSecurityChange(e.target.value as string)}
>
{network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
</Radio.Group>
</Form.Item>
)}
{security === 'tls' && tlsAllowed && <TlsForm />}
{security === 'reality' && realityAllowed && <RealityForm />}
{((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
<SockoptForm outboundTags={dialerProxyTags ?? existingTags} />
)}
<Controller
control={methods.control}
name="streamSettings.finalmask"
render={({ field }) => (
<FinalMaskField
key={`${protocol}:${network}`}
value={field.value}
onChange={field.onChange}
network={network}
protocol={protocol}
/>
)}
/>
<MuxForm protocol={protocol} network={network} />
</>
),
},
{
key: '2',
label: 'JSON',
children: (
<Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
<Input.Search
value={linkInput}
placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
enterButton="Import"
onChange={(e) => setLinkInput(e.target.value)}
onSearch={importLink}
/>
<JsonEditor
value={jsonText}
onChange={(next) => {
setJsonText(next);
setJsonDirty(true);
}}
minHeight="360px"
maxHeight="600px"
/>
</Space>
),
},
]}
/>
</Form>
</FormProvider>
</Modal> </Modal>
</> </>
); );
@@ -1,10 +1,12 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Select } from 'antd'; import { Select } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function BlackholeFields() { export default function BlackholeFields() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Form.Item label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}> <FormField label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
<Select <Select
options={[ options={[
{ value: '', label: '(empty)' }, { value: '', label: '(empty)' },
@@ -12,6 +14,6 @@ export default function BlackholeFields() {
{ value: 'http', label: 'http' }, { value: 'http', label: 'http' },
]} ]}
/> />
</Form.Item> </FormField>
); );
} }
@@ -1,15 +1,19 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select } from 'antd'; import { Button, Form, Input, InputNumber, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; 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 { activateOnKey } from '@/utils/a11y';
import { DNSRuleActions } from '@/schemas/primitives'; import { DNSRuleActions } from '@/schemas/primitives';
export default function DnsFields() { export default function DnsFields() {
const { t } = useTranslation(); const { t } = useTranslation();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({ control, name: 'settings.rules' });
return ( return (
<> <>
<Form.Item label={t('pages.xray.outboundForm.rewriteNetwork')} name={['settings', 'rewriteNetwork']}> <FormField label={t('pages.xray.outboundForm.rewriteNetwork')} name={['settings', 'rewriteNetwork']}>
<Select <Select
allowClear allowClear
placeholder={t('pages.xray.outboundForm.unchanged')} placeholder={t('pages.xray.outboundForm.unchanged')}
@@ -18,62 +22,56 @@ export default function DnsFields() {
{ value: 'tcp', label: 'tcp' }, { value: 'tcp', label: 'tcp' },
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.form.rewriteAddress')} name={['settings', 'rewriteAddress']}> <FormField label={t('pages.inbounds.form.rewriteAddress')} name={['settings', 'rewriteAddress']}>
<Input placeholder={t('pages.xray.outboundForm.unchangedAddress')} /> <Input placeholder={t('pages.xray.outboundForm.unchangedAddress')} />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}> <FormField label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
<InputNumber min={0} max={65535} style={{ width: '100%' }} /> <InputNumber min={0} max={65535} style={{ width: '100%' }} />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}> <FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Form.Item label={t('pages.xray.outboundForm.rules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => append({ action: 'direct', qType: '', domain: '', rCode: 0 })}
/>
</Form.Item> </Form.Item>
<Form.List name={['settings', 'rules']}> {fields.map((field, index) => (
{(fields, { add, remove }) => ( <div key={field.id}>
<> <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<Form.Item label={t('pages.xray.outboundForm.rules')}> <div className="item-heading">
<Button <span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
size="small" <DeleteOutlined
type="primary" className="danger-icon"
icon={<PlusOutlined />} role="button"
aria-label={t('add')} tabIndex={0}
onClick={() => add({ action: 'direct', qType: '', domain: '', rCode: 0 })} aria-label={t('remove')}
onClick={() => remove(index)}
onKeyDown={activateOnKey(() => remove(index))}
/> />
</Form.Item> </div>
{fields.map((field, index) => ( </Form.Item>
<div key={field.key}> <FormField label={t('pages.xray.outboundForm.action')} name={['settings', 'rules', index, 'action']}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}> <Select
<div className="item-heading"> options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span> />
<DeleteOutlined </FormField>
className="danger-icon" <FormField label="QType" name={['settings', 'rules', index, 'qType']}>
role="button" <Input placeholder="1,3,23-24" />
tabIndex={0} </FormField>
aria-label={t('remove')} <FormField label={t('domainName')} name={['settings', 'rules', index, 'domain']}>
onClick={() => remove(field.name)} <Input placeholder="domain:example.com" />
onKeyDown={activateOnKey(() => remove(field.name))} </FormField>
/> <FormField label="RCode" name={['settings', 'rules', index, 'rCode']}>
</div> <InputNumber min={0} max={65535} style={{ width: '100%' }} />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.outboundForm.action')} name={[field.name, 'action']}> </div>
<Select ))}
options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
/>
</Form.Item>
<Form.Item label="QType" name={[field.name, 'qType']}>
<Input placeholder="1,3,23-24" />
</Form.Item>
<Form.Item label={t('domainName')} name={[field.name, 'domain']}>
<Input placeholder="domain:example.com" />
</Form.Item>
<Form.Item label="RCode" name={[field.name, 'rCode']}>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
</> </>
); );
} }
@@ -1,30 +1,56 @@
import { useTranslation } from 'react-i18next'; 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 { 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 { activateOnKey } from '@/utils/a11y';
import { OutboundDomainStrategies } from '@/schemas/primitives'; import { OutboundDomainStrategies } from '@/schemas/primitives';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
export default function FreedomFields({ form }: { form: FormInstance<OutboundFormValues> }) { interface FragmentValue {
packets?: string;
length?: string;
interval?: string;
maxSplit?: string;
}
export default function FreedomFields() {
const { t } = useTranslation(); 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 ( return (
<> <>
<Form.Item label={t('pages.xray.balancer.balancerStrategy')} name={['settings', 'domainStrategy']}> <FormField label={t('pages.xray.balancer.balancerStrategy')} name={['settings', 'domainStrategy']}>
<Select <Select
options={[ options={[
{ value: '', label: `(${t('none')})` }, { value: '', label: `(${t('none')})` },
...OutboundDomainStrategies.map((s) => ({ value: s, label: s })), ...OutboundDomainStrategies.map((s) => ({ value: s, label: s })),
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}> <FormField label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}> <FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}> <FormField label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
<Select <Select
options={[ options={[
{ value: 0, label: `(${t('none')})` }, { value: 0, label: `(${t('none')})` },
@@ -32,260 +58,190 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
{ value: 2, label: 'v2' }, { value: 2, label: 'v2' },
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.outboundForm.fragment')} shouldUpdate noStyle> <Form.Item label="Fragment">
{() => { <Switch
const fragment = (form.getFieldValue(['settings', 'fragment']) ?? {}) as { checked={fragmentEnabled}
packets?: string; onChange={(checked) => {
length?: string; setValue(
interval?: string; 'settings.fragment',
maxSplit?: string; checked
}; ? {
const enabled = !!(fragment.length || fragment.interval || fragment.maxSplit); packets: 'tlshello',
return ( length: '100-200',
<> interval: '10-20',
<Form.Item label="Fragment"> maxSplit: '300-400',
<Switch
checked={enabled}
onChange={(checked) => {
form.setFieldValue(
['settings', 'fragment'],
checked
? {
packets: 'tlshello',
length: '100-200',
interval: '10-20',
maxSplit: '300-400',
}
: { packets: '', length: '', interval: '', maxSplit: '' },
);
}}
/>
</Form.Item>
{enabled && (
<>
<Form.Item
label={t('pages.settings.subFormats.packets')}
name={['settings', 'fragment', 'packets']}
rules={[{
validator: (_rule, value) => {
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'));
},
}]}
>
<AutoComplete
options={[
{ value: 'tlshello', label: 'tlshello' },
{ value: '1-3', label: '1-3' },
{ value: '1-5', label: '1-5' },
]}
placeholder="tlshello or n-m, e.g. 1-3"
/>
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
<Input />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.interval')}
name={['settings', 'fragment', 'interval']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.maxSplit')}
name={['settings', 'fragment', 'maxSplit']}
>
<Input />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
<Form.List name={['settings', 'noises']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.settings.subFormats.noises')}>
<Switch
checked={fields.length > 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 && (
<Button
size="small"
type="primary"
className="ml-8"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
add({
type: 'rand',
packet: '10-20',
delay: '10-16',
applyTo: 'ip',
})
}
/>
)}
</Form.Item>
{fields.map((field, index) => (
<div key={field.key}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
{fields.length > 1 && (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
)}
</div>
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.type')} name={[field.name, 'type']}>
<Select
options={['rand', 'base64', 'str', 'hex'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.packet')} name={[field.name, 'packet']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.delayMs')} name={[field.name, 'delay']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.applyTo')} name={[field.name, 'applyTo']}>
<Select
options={['ip', 'ipv4', 'ipv6'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
</div>
))}
</>
)}
</Form.List>
<Form.List name={['settings', 'finalRules']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.xray.outboundForm.finalRules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
add({
action: 'allow',
network: '',
port: '',
ip: [],
blockDelay: '',
})
} }
/> : { packets: '', length: '', interval: '', maxSplit: '' },
<span className="ml-8" style={{ opacity: 0.6 }}> );
{t('pages.xray.outboundForm.overrideXrayPrivateIp')} }}
</span> />
</Form.Item> </Form.Item>
{fields.map((field, index) => ( {fragmentEnabled && (
<div key={field.key}> <>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}> <FormField
<div className="item-heading"> label={t('pages.settings.subFormats.packets')}
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span> name={['settings', 'fragment', 'packets']}
<DeleteOutlined rules={{
className="danger-icon" validate: (value) => {
role="button" const str = String(value ?? '').trim();
tabIndex={0} /* xray accepts "tlshello" or any packet-number range (#5075) */
aria-label={t('remove')} if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) return true;
onClick={() => remove(field.name)} return 'Use "tlshello" or a packet range like 1-3';
onKeyDown={activateOnKey(() => remove(field.name))} },
/> }}
</div> >
</Form.Item> <AutoComplete
<Form.Item label={t('pages.xray.outboundForm.action')} name={[field.name, 'action']}> options={[
<Select { value: 'tlshello', label: 'tlshello' },
options={['allow', 'block'].map((v) => ({ { value: '1-3', label: '1-3' },
value: v, { value: '1-5', label: '1-5' },
label: v, ]}
}))} placeholder="tlshello or n-m, e.g. 1-3"
/> />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.network')} name={[field.name, 'network']}> <FormField label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
<Select <Input />
allowClear </FormField>
placeholder="(any)" <FormField label={t('pages.settings.subFormats.interval')} name={['settings', 'fragment', 'interval']}>
options={['tcp', 'udp', 'tcp,udp'].map((v) => ({ <Input />
value: v, </FormField>
label: v, <FormField label={t('pages.settings.subFormats.maxSplit')} name={['settings', 'fragment', 'maxSplit']}>
}))} <Input />
/> </FormField>
</Form.Item> </>
<Form.Item label={t('pages.inbounds.port')} name={[field.name, 'port']}> )}
<Input placeholder="e.g. 80,443 or 1000-2000" />
</Form.Item> <Form.Item label={t('pages.settings.subFormats.noises')}>
<Form.Item label="IP / CIDR / geoip" name={[field.name, 'ip']}> <Switch
<Select checked={noiseFields.length > 0}
mode="tags" onChange={(checked) => {
tokenSeparators={[',', ' ']} if (checked) {
placeholder="e.g. 10.0.0.0/8, geoip:private" appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' });
/> } else {
</Form.Item> removeNoise();
<Form.Item shouldUpdate noStyle> }
{() => { }}
const ruleAction = form.getFieldValue([ />
'settings', {noiseFields.length > 0 && (
'finalRules', <Button
field.name, size="small"
'action', type="primary"
]); className="ml-8"
if (ruleAction !== 'block') return null; icon={<PlusOutlined />}
return ( aria-label={t('add')}
<Form.Item onClick={() => appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })}
label={t('pages.xray.outboundForm.blockDelay')} />
name={[field.name, 'blockDelay']}
>
<Input placeholder="optional: 5000-10000" />
</Form.Item>
);
}}
</Form.Item>
</div>
))}
</>
)} )}
</Form.List> </Form.Item>
{noiseFields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
{noiseFields.length > 1 && (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removeNoise(index)}
onKeyDown={activateOnKey(() => removeNoise(index))}
/>
)}
</div>
</Form.Item>
<FormField label={t('pages.settings.subFormats.type')} name={['settings', 'noises', index, 'type']}>
<Select
options={['rand', 'base64', 'str', 'hex'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField label={t('pages.settings.subFormats.packet')} name={['settings', 'noises', index, 'packet']}>
<Input />
</FormField>
<FormField label={t('pages.settings.subFormats.delayMs')} name={['settings', 'noises', index, 'delay']}>
<Input />
</FormField>
<FormField label={t('pages.settings.subFormats.applyTo')} name={['settings', 'noises', index, 'applyTo']}>
<Select
options={['ip', 'ipv4', 'ipv6'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
</div>
))}
<Form.Item label={t('pages.xray.outboundForm.finalRules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })}
/>
<span className="ml-8" style={{ opacity: 0.6 }}>
{t('pages.xray.outboundForm.overrideXrayPrivateIp')}
</span>
</Form.Item>
{finalRuleFields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removeFinalRule(index)}
onKeyDown={activateOnKey(() => removeFinalRule(index))}
/>
</div>
</Form.Item>
<FormField label={t('pages.xray.outboundForm.action')} name={['settings', 'finalRules', index, 'action']}>
<Select
options={['allow', 'block'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField label={t('pages.inbounds.network')} name={['settings', 'finalRules', index, 'network']}>
<Select
allowClear
placeholder="(any)"
options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField label={t('pages.inbounds.port')} name={['settings', 'finalRules', index, 'port']}>
<Input placeholder="e.g. 80,443 or 1000-2000" />
</FormField>
<FormField label="IP / CIDR / geoip" name={['settings', 'finalRules', index, 'ip']}>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder="e.g. 10.0.0.0/8, geoip:private"
/>
</FormField>
{finalRulesValues[index]?.action === 'block' && (
<FormField
label={t('pages.xray.outboundForm.blockDelay')}
name={['settings', 'finalRules', index, 'blockDelay']}
>
<Input placeholder="optional: 5000-10000" />
</FormField>
)}
</div>
))}
</> </>
); );
} }
@@ -1,21 +1,22 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd'; import { Input } from 'antd';
import { HeaderMapEditor } from '@/components/form'; import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function HttpFields() { export default function HttpFields() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item label={t('username')} name={['settings', 'user']}> <FormField label={t('username')} name={['settings', 'user']}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item label={t('password')} name={['settings', 'pass']}> <FormField label={t('password')} name={['settings', 'pass']}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}> <FormField label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
<HeaderMapEditor mode="v1" /> <HeaderMapEditor mode="v1" />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,22 +1,30 @@
import { useTranslation } from 'react-i18next'; 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() { export default function LoopbackFields() {
const { t } = useTranslation(); const { t } = useTranslation();
const form = Form.useFormInstance(); const { control } = useFormContext();
return ( return (
<> <>
<Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}> <FormField label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
<Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} /> <Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
</Form.Item> </FormField>
<SniffingFields <Controller
name={['settings', 'sniffing']} control={control}
form={form} name="settings.sniffing"
enableLabel={t('pages.inbounds.sniffingTab')} render={({ field }) => (
<SniffingField
value={field.value}
onChange={field.onChange}
enableLabel={t('pages.inbounds.sniffingTab')}
/>
)}
/> />
</> </>
); );
@@ -1,24 +1,28 @@
import { useTranslation } from 'react-i18next'; 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() { export default function ServerTarget() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item <FormField
label={t('pages.inbounds.address')} label={t('pages.inbounds.address')}
name={['settings', 'address']} name={['settings', 'address']}
rules={[{ required: true, message: t('pages.xray.outboundForm.addressRequired') }]} required
rules={{ required: 'pages.xray.outboundForm.addressRequired' }}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.port')} label={t('pages.inbounds.port')}
name={['settings', 'port']} name={['settings', 'port']}
rules={[{ required: true, message: t('pages.xray.outboundForm.portRequired') }]} required
rules={{ required: 'pages.xray.outboundForm.portRequired' }}
> >
<InputNumber min={1} max={65535} style={{ width: '100%' }} /> <InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,9 +1,9 @@
import { useTranslation } from 'react-i18next'; 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 { ShadowsocksOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks'; import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
import { antdRule } from '@/utils/zodForm';
import { SS_METHOD_OPTIONS } from '../outbound-form-constants'; import { SS_METHOD_OPTIONS } from '../outbound-form-constants';
@@ -11,30 +11,30 @@ export default function ShadowsocksFields() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item <FormField
label={t('password')} label={t('password')}
name={['settings', 'password']} name={['settings', 'password']}
rules={[antdRule(ShadowsocksOutboundFormSettingsSchema.shape.password, t)]} rules={{ validate: rhfZodValidate(ShadowsocksOutboundFormSettingsSchema.shape.password) }}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('encryption')} label={t('encryption')}
name={['settings', 'method']} name={['settings', 'method']}
rules={[antdRule(SSMethodSchema, t)]} rules={{ validate: rhfZodValidate(SSMethodSchema) }}
> >
<Select options={SS_METHOD_OPTIONS} /> <Select options={SS_METHOD_OPTIONS} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.xray.outboundForm.udpOverTcp')} label={t('pages.xray.outboundForm.udpOverTcp')}
name={['settings', 'uot']} name={['settings', 'uot']}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.outboundForm.uotVersion')} name={['settings', 'UoTVersion']}> <FormField label={t('pages.xray.outboundForm.uotVersion')} name={['settings', 'UoTVersion']}>
<InputNumber min={1} max={2} /> <InputNumber min={1} max={2} />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,16 +1,18 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd'; import { Input } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function SocksFields() { export default function SocksFields() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item label={t('username')} name={['settings', 'user']}> <FormField label={t('username')} name={['settings', 'user']}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item label={t('password')} name={['settings', 'pass']}> <FormField label={t('password')} name={['settings', 'pass']}>
<Input /> <Input />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,18 +1,18 @@
import { useTranslation } from 'react-i18next'; 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 { TrojanOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { antdRule } from '@/utils/zodForm';
export default function TrojanFields() { export default function TrojanFields() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Form.Item <FormField
label={t('password')} label={t('password')}
name={['settings', 'password']} name={['settings', 'password']}
rules={[antdRule(TrojanOutboundFormSettingsSchema.shape.password, t)]} rules={{ validate: rhfZodValidate(TrojanOutboundFormSettingsSchema.shape.password) }}
> >
<Input /> <Input />
</Form.Item> </FormField>
); );
} }
@@ -1,33 +1,33 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd'; import { Input } from 'antd';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { import {
VlessOutboundFormSettingsSchema, VlessOutboundFormSettingsSchema,
VmessOutboundFormSettingsSchema, VmessOutboundFormSettingsSchema,
} from '@/schemas/forms/outbound-form'; } from '@/schemas/forms/outbound-form';
import { antdRule } from '@/utils/zodForm';
export default function VlessFields() { export default function VlessFields() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item <FormField
label="ID" label="ID"
name={['settings', 'id']} name={['settings', 'id']}
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.id, t)]} rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.id) }}
> >
<Input placeholder="UUID" /> <Input placeholder="UUID" />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('encryption')} label={t('encryption')}
name={['settings', 'encryption']} name={['settings', 'encryption']}
rules={[antdRule(VlessOutboundFormSettingsSchema.shape.encryption, t)]} rules={{ validate: rhfZodValidate(VlessOutboundFormSettingsSchema.shape.encryption) }}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item label={t('pages.clients.reverseTag')} name={['settings', 'reverseTag']}> <FormField label={t('pages.clients.reverseTag')} name={['settings', 'reverseTag']}>
<Input placeholder={t('pages.xray.outboundForm.optional')} /> <Input placeholder={t('pages.xray.outboundForm.optional')} />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next'; 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 { VmessOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { antdRule } from '@/utils/zodForm';
import { SECURITY_OPTIONS } from '../outbound-form-constants'; import { SECURITY_OPTIONS } from '../outbound-form-constants';
@@ -10,20 +10,20 @@ export default function VmessFields() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item <FormField
label="ID" label="ID"
name={['settings', 'id']} name={['settings', 'id']}
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.id, t)]} rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.id) }}
> >
<Input placeholder="UUID" /> <Input placeholder="UUID" />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('security')} label={t('security')}
name={['settings', 'security']} name={['settings', 'security']}
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.security, t)]} rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.security) }}
> >
<Select options={SECURITY_OPTIONS} /> <Select options={SECURITY_OPTIONS} />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,147 +1,149 @@
import { useTranslation } from 'react-i18next'; 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 { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Wireguard } from '@/utils'; import { Wireguard } from '@/utils';
import { activateOnKey } from '@/utils/a11y'; import { activateOnKey } from '@/utils/a11y';
import { InputAddon } from '@/components/ui'; import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
import { WireguardDomainStrategy } from '@/schemas/primitives'; import { WireguardDomainStrategy } from '@/schemas/primitives';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
export default function WireguardFields({ form }: { form: FormInstance<OutboundFormValues> }) { function AllowedIPsList({ peerIndex }: { peerIndex: number }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({
control,
name: `settings.peers.${peerIndex}.allowedIPs`,
});
return ( return (
<> <>
<Form.Item label={t('pages.inbounds.address')} name={['settings', 'address']}> {fields.map((field, ipIdx) => (
<Space.Compact key={field.id} block style={{ marginBottom: 4 }}>
<FormField noStyle name={['settings', 'peers', peerIndex, 'allowedIPs', ipIdx]}>
<Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
</FormField>
{fields.length > 1 && (
<InputAddon ariaLabel={t('remove')} onClick={() => remove(ipIdx)}>
<MinusOutlined />
</InputAddon>
)}
</Space.Compact>
))}
<Button
size="small"
icon={<PlusOutlined />}
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 (
<>
<FormField label={t('pages.inbounds.address')} name={['settings', 'address']}>
<Input placeholder="comma-separated, e.g. 10.0.0.1,fd00::1" /> <Input placeholder="comma-separated, e.g. 10.0.0.1,fd00::1" />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.privatekey')}> <Form.Item label={t('pages.inbounds.privatekey')}>
<Space.Compact block> <Space.Compact block>
<Form.Item name={['settings', 'secretKey']} noStyle> <FormField name={['settings', 'secretKey']} noStyle>
<Input aria-label={t('pages.inbounds.privatekey')} style={{ width: 'calc(100% - 32px)' }} /> <Input aria-label={t('pages.inbounds.privatekey')} style={{ width: 'calc(100% - 32px)' }} />
</Form.Item> </FormField>
<Button <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
aria-label={t('regenerate')} aria-label={t('regenerate')}
onClick={() => { onClick={() => {
const pair = Wireguard.generateKeypair(); const pair = Wireguard.generateKeypair();
form.setFieldValue(['settings', 'secretKey'], pair.privateKey); setValue('settings.secretKey', pair.privateKey);
form.setFieldValue(['settings', 'pubKey'], pair.publicKey); setValue('settings.pubKey', pair.publicKey);
}} }}
/> />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}> <FormField label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
<Input disabled /> <Input disabled />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.wireguard.domainStrategy')} name={['settings', 'domainStrategy']}> <FormField label={t('pages.xray.wireguard.domainStrategy')} name={['settings', 'domainStrategy']}>
<Select <Select
options={[ options={[
{ value: '', label: `(${t('none')})` }, { value: '', label: `(${t('none')})` },
...WireguardDomainStrategy.map((s) => ({ value: s, label: s })), ...WireguardDomainStrategy.map((s) => ({ value: s, label: s })),
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item label="MTU" name={['settings', 'mtu']}> <FormField label="MTU" name={['settings', 'mtu']}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.info.noKernelTun')} label={t('pages.inbounds.info.noKernelTun')}
name={['settings', 'noKernelTun']} name={['settings', 'noKernelTun']}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}> <FormField label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
<Input placeholder="comma-separated bytes, e.g. 1,2,3" /> <Input placeholder="comma-separated bytes, e.g. 1,2,3" />
</FormField>
<Form.Item label={t('pages.inbounds.form.peers')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
appendPeer({
publicKey: '',
psk: '',
allowedIPs: ['0.0.0.0/0', '::/0'],
endpoint: '',
keepAlive: 0,
})
}
/>
</Form.Item> </Form.Item>
<Form.List name={['settings', 'peers']}> {peerFields.map((field, index) => (
{(fields, { add, remove }) => ( <div key={field.id}>
<> <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<Form.Item label={t('pages.inbounds.form.peers')}> <div className="item-heading">
<Button <span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span>
size="small" {peerFields.length > 1 && (
type="primary" <DeleteOutlined
icon={<PlusOutlined />} className="danger-icon"
aria-label={t('add')} role="button"
onClick={() => tabIndex={0}
add({ aria-label={t('remove')}
publicKey: '', onClick={() => removePeer(index)}
psk: '', onKeyDown={activateOnKey(() => removePeer(index))}
allowedIPs: ['0.0.0.0/0', '::/0'], />
endpoint: '', )}
keepAlive: 0, </div>
}) </Form.Item>
} <FormField label={t('pages.xray.wireguard.endpoint')} name={['settings', 'peers', index, 'endpoint']}>
/> <Input />
</Form.Item> </FormField>
{fields.map((field, index) => ( <FormField label={t('pages.inbounds.publicKey')} name={['settings', 'peers', index, 'publicKey']}>
<div key={field.key}> <Input />
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}> </FormField>
<div className="item-heading"> <FormField label="PSK" name={['settings', 'peers', index, 'psk']}>
<span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span> <Input />
{fields.length > 1 && ( </FormField>
<DeleteOutlined <Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
className="danger-icon" <AllowedIPsList peerIndex={index} />
role="button" </Form.Item>
tabIndex={0} <FormField label={t('pages.inbounds.info.keepAlive')} name={['settings', 'peers', index, 'keepAlive']}>
aria-label={t('remove')} <InputNumber min={0} />
onClick={() => remove(field.name)} </FormField>
onKeyDown={activateOnKey(() => remove(field.name))} </div>
/> ))}
)}
</div>
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.endpoint')} name={[field.name, 'endpoint']}>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.publicKey')}
name={[field.name, 'publicKey']}
>
<Input />
</Form.Item>
<Form.Item label="PSK" name={[field.name, 'psk']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
<Form.List name={[field.name, 'allowedIPs']}>
{(ipFields, { add: addIp, remove: removeIp }) => (
<>
{ipFields.map((ipField, ipIdx) => (
<Space.Compact
key={ipField.key}
block
style={{ marginBottom: 4 }}
>
<Form.Item noStyle name={ipField.name}>
<Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
</Form.Item>
{ipFields.length > 1 && (
<InputAddon ariaLabel={t('remove')} onClick={() => removeIp(ipIdx)}>
<MinusOutlined />
</InputAddon>
)}
</Space.Compact>
))}
<Button
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => addIp('')}
/>
</>
)}
</Form.List>
</Form.Item>
<Form.Item label={t('pages.inbounds.info.keepAlive')} name={[field.name, 'keepAlive']}>
<InputNumber min={0} />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
</> </>
); );
} }
@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next'; 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'; import { UTLS_OPTIONS } from '../outbound-form-constants';
@@ -7,42 +9,42 @@ export default function RealityForm() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item <FormField
label="SNI" label="SNI"
name={['streamSettings', 'realitySettings', 'serverName']} name={['streamSettings', 'realitySettings', 'serverName']}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label="uTLS" label="uTLS"
name={['streamSettings', 'realitySettings', 'fingerprint']} name={['streamSettings', 'realitySettings', 'fingerprint']}
> >
<Select options={UTLS_OPTIONS} /> <Select options={UTLS_OPTIONS} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.xray.outboundForm.shortId')} label={t('pages.xray.outboundForm.shortId')}
name={['streamSettings', 'realitySettings', 'shortId']} name={['streamSettings', 'realitySettings', 'shortId']}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.spiderX')} label={t('pages.inbounds.form.spiderX')}
name={['streamSettings', 'realitySettings', 'spiderX']} name={['streamSettings', 'realitySettings', 'spiderX']}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.publicKey')} label={t('pages.inbounds.publicKey')}
name={['streamSettings', 'realitySettings', 'publicKey']} name={['streamSettings', 'realitySettings', 'publicKey']}
> >
<Input.TextArea autoSize={{ minRows: 2 }} /> <Input.TextArea autoSize={{ minRows: 2 }} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.mldsa65Verify')} label={t('pages.inbounds.form.mldsa65Verify')}
name={['streamSettings', 'realitySettings', 'mldsa65Verify']} name={['streamSettings', 'realitySettings', 'mldsa65Verify']}
> >
<Input.TextArea autoSize={{ minRows: 2 }} /> <Input.TextArea autoSize={{ minRows: 2 }} />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next'; 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'; import { ALPN_OPTIONS, UTLS_OPTIONS } from '../outbound-form-constants';
@@ -7,13 +9,13 @@ export default function TlsForm() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item <FormField
label="SNI" label="SNI"
name={['streamSettings', 'tlsSettings', 'serverName']} name={['streamSettings', 'tlsSettings', 'serverName']}
> >
<Input placeholder={t('pages.xray.outboundForm.serverNamePlaceholder')} /> <Input placeholder={t('pages.xray.outboundForm.serverNamePlaceholder')} />
</Form.Item> </FormField>
<Form.Item <FormField
label="uTLS" label="uTLS"
name={['streamSettings', 'tlsSettings', 'fingerprint']} name={['streamSettings', 'tlsSettings', 'fingerprint']}
> >
@@ -22,31 +24,31 @@ export default function TlsForm() {
placeholder={t('none')} placeholder={t('none')}
options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS]} options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS]}
/> />
</Form.Item> </FormField>
<Form.Item <FormField
label="ALPN" label="ALPN"
name={['streamSettings', 'tlsSettings', 'alpn']} name={['streamSettings', 'tlsSettings', 'alpn']}
> >
<Select mode="multiple" options={ALPN_OPTIONS} /> <Select mode="multiple" options={ALPN_OPTIONS} />
</Form.Item> </FormField>
<Form.Item <FormField
label="ECH" label="ECH"
name={['streamSettings', 'tlsSettings', 'echConfigList']} name={['streamSettings', 'tlsSettings', 'echConfigList']}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.xray.outboundForm.verifyPeerName')} label={t('pages.xray.outboundForm.verifyPeerName')}
name={['streamSettings', 'tlsSettings', 'verifyPeerCertByName']} name={['streamSettings', 'tlsSettings', 'verifyPeerCertByName']}
> >
<Input placeholder="cloudflare-dns.com" /> <Input placeholder="cloudflare-dns.com" />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.xray.outboundForm.pinnedSha256')} label={t('pages.xray.outboundForm.pinnedSha256')}
name={['streamSettings', 'tlsSettings', 'pinnedPeerCertSha256']} name={['streamSettings', 'tlsSettings', 'pinnedPeerCertSha256']}
> >
<Input placeholder="base64 SHA256" /> <Input placeholder="base64 SHA256" />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,29 +1,31 @@
import { useTranslation } from 'react-i18next'; 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() { export default function GrpcForm() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item <FormField
label={t('pages.inbounds.form.serviceName')} label={t('pages.inbounds.form.serviceName')}
name={['streamSettings', 'grpcSettings', 'serviceName']} name={['streamSettings', 'grpcSettings', 'serviceName']}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.authority')} label={t('pages.inbounds.form.authority')}
name={['streamSettings', 'grpcSettings', 'authority']} name={['streamSettings', 'grpcSettings', 'authority']}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.multiMode')} label={t('pages.inbounds.form.multiMode')}
name={['streamSettings', 'grpcSettings', 'multiMode']} name={['streamSettings', 'grpcSettings', 'multiMode']}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,30 +1,31 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd'; import { Input } from 'antd';
import { HeaderMapEditor } from '@/components/form'; import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function HttpUpgradeForm() { export default function HttpUpgradeForm() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item <FormField
label={t('host')} label={t('host')}
name={['streamSettings', 'httpupgradeSettings', 'host']} name={['streamSettings', 'httpupgradeSettings', 'host']}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('path')} label={t('path')}
name={['streamSettings', 'httpupgradeSettings', 'path']} name={['streamSettings', 'httpupgradeSettings', 'path']}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.headers')} label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'httpupgradeSettings', 'headers']} name={['streamSettings', 'httpupgradeSettings', 'headers']}
> >
<HeaderMapEditor mode="v1" /> <HeaderMapEditor mode="v1" />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,134 +1,108 @@
import { useTranslation } from 'react-i18next'; 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 { 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 { t } = useTranslation();
const { control, setValue } = useFormContext();
const masquerade = useWatch({ control, name: MASQ_DOT }) as { type?: string } | undefined;
return ( return (
<> <>
<Form.Item <FormField
label={t('pages.inbounds.form.version')} label={t('pages.inbounds.form.version')}
name={['streamSettings', 'hysteriaSettings', 'version']} name={['streamSettings', 'hysteriaSettings', 'version']}
> >
<InputNumber min={2} max={2} disabled style={{ width: '100%' }} /> <InputNumber min={2} max={2} disabled style={{ width: '100%' }} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.xray.outboundForm.authPassword')} label={t('pages.xray.outboundForm.authPassword')}
name={['streamSettings', 'hysteriaSettings', 'auth']} name={['streamSettings', 'hysteriaSettings', 'auth']}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.udpIdleTimeout')} label={t('pages.inbounds.form.udpIdleTimeout')}
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']} name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
> >
<InputNumber min={2} max={600} style={{ width: '100%' }} /> <InputNumber min={2} max={600} style={{ width: '100%' }} />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.form.masquerade')}> <Form.Item label={t('pages.inbounds.form.masquerade')}>
<Form.Item shouldUpdate noStyle> <Switch
{() => { checked={!!masquerade}
const m = form.getFieldValue(MASQ_PATH); onChange={(checked) =>
return ( setValue(
<Switch MASQ_DOT,
checked={!!m} checked
onChange={(checked) => ? {
form.setFieldValue( type: '', dir: '', url: '',
MASQ_PATH, rewriteHost: false, insecure: false,
checked content: '', headers: {}, statusCode: 0,
? {
type: '', dir: '', url: '',
rewriteHost: false, insecure: false,
content: '', headers: {}, statusCode: 0,
}
: undefined,
)
} }
/> : undefined,
); )
}} }
</Form.Item> />
</Form.Item> </Form.Item>
<Form.Item shouldUpdate noStyle> {masquerade && (
{() => { <>
const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined; <FormField label={t('pages.inbounds.form.type')} name={[...MASQ, 'type']}>
if (!m) return null; <Select
return ( options={[
{ value: '', label: 'default (404 page)' },
{ value: 'proxy', label: 'proxy (reverse proxy)' },
{ value: 'file', label: 'file (serve directory)' },
{ value: 'string', label: 'string (fixed body)' },
]}
/>
</FormField>
{masquerade.type === 'proxy' && (
<> <>
<Form.Item <FormField label={t('pages.inbounds.form.upstreamUrl')} name={[...MASQ, 'url']}>
label={t('pages.inbounds.form.type')} <Input placeholder="https://www.example.com" />
name={[...MASQ_PATH, 'type']} </FormField>
<FormField
label={t('pages.inbounds.form.rewriteHost')}
name={[...MASQ, 'rewriteHost']}
valueProp="checked"
> >
<Select <Switch />
options={[ </FormField>
{ value: '', label: 'default (404 page)' }, <FormField
{ value: 'proxy', label: 'proxy (reverse proxy)' }, label={t('pages.inbounds.form.skipTlsVerify')}
{ value: 'file', label: 'file (serve directory)' }, name={[...MASQ, 'insecure']}
{ value: 'string', label: 'string (fixed body)' }, valueProp="checked"
]} >
/> <Switch />
</Form.Item> </FormField>
{m.type === 'proxy' && (
<>
<Form.Item
label={t('pages.inbounds.form.upstreamUrl')}
name={[...MASQ_PATH, 'url']}
>
<Input placeholder="https://www.example.com" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.rewriteHost')}
name={[...MASQ_PATH, 'rewriteHost']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.skipTlsVerify')}
name={[...MASQ_PATH, 'insecure']}
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
{m.type === 'file' && (
<Form.Item
label={t('pages.inbounds.form.directory')}
name={[...MASQ_PATH, 'dir']}
>
<Input placeholder="/var/www/html" />
</Form.Item>
)}
{m.type === 'string' && (
<>
<Form.Item
label={t('pages.inbounds.form.statusCode')}
name={[...MASQ_PATH, 'statusCode']}
>
<InputNumber min={0} max={599} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.body')}
name={[...MASQ_PATH, 'content']}
>
<Input.TextArea autoSize={{ minRows: 3 }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.headers')}
name={[...MASQ_PATH, 'headers']}
>
<HeaderMapEditor mode="v1" />
</Form.Item>
</>
)}
</> </>
); )}
}} {masquerade.type === 'file' && (
</Form.Item> <FormField label={t('pages.inbounds.form.directory')} name={[...MASQ, 'dir']}>
<Input placeholder="/var/www/html" />
</FormField>
)}
{masquerade.type === 'string' && (
<>
<FormField label={t('pages.inbounds.form.statusCode')} name={[...MASQ, 'statusCode']}>
<InputNumber min={0} max={599} style={{ width: '100%' }} />
</FormField>
<FormField label={t('pages.inbounds.form.body')} name={[...MASQ, 'content']}>
<Input.TextArea autoSize={{ minRows: 3 }} />
</FormField>
<FormField label={t('pages.inbounds.form.headers')} name={[...MASQ, 'headers']}>
<HeaderMapEditor mode="v1" />
</FormField>
</>
)}
</>
)}
</> </>
); );
} }
@@ -1,40 +1,42 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, InputNumber } from 'antd'; import { InputNumber } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function KcpForm() { export default function KcpForm() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}> <FormField label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item label={t('pages.inbounds.form.ttiMs')} name={['streamSettings', 'kcpSettings', 'tti']}> <FormField label={t('pages.inbounds.form.ttiMs')} name={['streamSettings', 'kcpSettings', 'tti']}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.uplinkMbps')} label={t('pages.inbounds.form.uplinkMbps')}
name={['streamSettings', 'kcpSettings', 'uplinkCapacity']} name={['streamSettings', 'kcpSettings', 'uplinkCapacity']}
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.downlinkMbps')} label={t('pages.inbounds.form.downlinkMbps')}
name={['streamSettings', 'kcpSettings', 'downlinkCapacity']} name={['streamSettings', 'kcpSettings', 'downlinkCapacity']}
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.cwndMultiplier')} label={t('pages.inbounds.form.cwndMultiplier')}
name={['streamSettings', 'kcpSettings', 'cwndMultiplier']} name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
> >
<InputNumber min={1} /> <InputNumber min={1} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.maxSendingWindow')} label={t('pages.inbounds.form.maxSendingWindow')}
name={['streamSettings', 'kcpSettings', 'maxSendingWindow']} name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,63 +1,58 @@
import { useTranslation } from 'react-i18next'; 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'; import { isMuxAllowed } from '../outbound-form-helpers';
interface MuxFormProps { interface MuxFormProps {
form: FormInstance<OutboundFormValues>;
protocol: string; protocol: string;
network: string; network: string;
} }
export default function MuxForm({ form, protocol, network }: MuxFormProps) { export default function MuxForm({ protocol, network }: MuxFormProps) {
const { t } = useTranslation(); 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; if (!isMuxAllowed(protocol, flow, network)) return null;
return ( return (
<Form.Item shouldUpdate noStyle> <>
{() => { <FormField
const muxEnabled = !!form.getFieldValue(['mux', 'enabled']); label={t('pages.settings.mux')}
return ( name={['mux', 'enabled']}
<> valueProp="checked"
<Form.Item >
label={t('pages.settings.mux')} <Switch />
name={['mux', 'enabled']} </FormField>
valuePropName="checked" {muxEnabled && (
> <>
<Switch /> <FormField
</Form.Item> label={t('pages.settings.subFormats.concurrency')}
{muxEnabled && ( name={['mux', 'concurrency']}
<> >
<Form.Item <InputNumber min={-1} max={1024} />
label={t('pages.settings.subFormats.concurrency')} </FormField>
name={['mux', 'concurrency']} <FormField
> label={t('pages.settings.subFormats.xudpConcurrency')}
<InputNumber min={-1} max={1024} /> name={['mux', 'xudpConcurrency']}
</Form.Item> >
<Form.Item <InputNumber min={-1} max={1024} />
label={t('pages.settings.subFormats.xudpConcurrency')} </FormField>
name={['mux', 'xudpConcurrency']} <FormField
> label={t('pages.settings.subFormats.xudpUdp443')}
<InputNumber min={-1} max={1024} /> name={['mux', 'xudpProxyUDP443']}
</Form.Item> >
<Form.Item <Select
label={t('pages.settings.subFormats.xudpUdp443')} options={['reject', 'allow', 'skip'].map((v) => ({
name={['mux', 'xudpProxyUDP443']} value: v,
> label: v,
<Select }))}
options={['reject', 'allow', 'skip'].map((v) => ({ />
value: v, </FormField>
label: v, </>
}))} )}
/> </>
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
); );
} }
@@ -1,136 +1,109 @@
import { useTranslation } from 'react-i18next'; 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 { 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<OutboundFormValues> }) { export default function RawForm() {
const { t } = useTranslation(); const { t } = useTranslation();
const { control, setValue } = useFormContext();
const type = (useWatch({
control,
name: 'streamSettings.tcpSettings.header.type',
}) ?? 'none') as string;
return ( return (
<Form.Item shouldUpdate noStyle> <>
{() => { <Form.Item label={`HTTP ${t('camouflage')}`}>
const type = <Switch
form.getFieldValue([ checked={type === 'http'}
'streamSettings', onChange={(checked) =>
'tcpSettings', setValue(
'header', 'streamSettings.tcpSettings.header',
'type', checked
]) ?? 'none'; ? {
return ( type: 'http',
<> request: {
<Form.Item label={`HTTP ${t('camouflage')}`}> version: '1.1',
<Switch method: 'GET',
checked={type === 'http'} path: ['/'],
onChange={(checked) => headers: {},
form.setFieldValue( },
['streamSettings', 'tcpSettings', 'header'], response: {
checked version: '1.1',
? { status: '200',
type: 'http', reason: 'OK',
request: { headers: {},
version: '1.1', },
method: 'GET',
path: ['/'],
headers: {},
},
response: {
version: '1.1',
status: '200',
reason: 'OK',
headers: {},
},
}
: { type: 'none' },
)
} }
/> : { type: 'none' },
</Form.Item> )
{type === 'http' && ( }
<> />
<Form.Item </Form.Item>
label={t('pages.inbounds.form.requestVersion')} {type === 'http' && (
name={[ <>
'streamSettings', 'tcpSettings', 'header', <FormField
'request', 'version', label={t('pages.inbounds.form.requestVersion')}
]} name={['streamSettings', 'tcpSettings', 'header', 'request', 'version']}
> >
<Input placeholder="1.1" /> <Input placeholder="1.1" />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.requestMethod')} label={t('pages.inbounds.form.requestMethod')}
name={[ name={['streamSettings', 'tcpSettings', 'header', 'request', 'method']}
'streamSettings', 'tcpSettings', 'header', >
'request', 'method', <Input placeholder="GET" />
]} </FormField>
> <FormField
<Input placeholder="GET" /> label={t('pages.inbounds.form.requestPath')}
</Form.Item> name={['streamSettings', 'tcpSettings', 'header', 'request', 'path']}
<Form.Item transform={{
label={t('pages.inbounds.form.requestPath')} input: (v) => (Array.isArray(v) ? v.join(',') : v),
name={[ output: (raw) => {
'streamSettings', 'tcpSettings', 'header', const parts = String(raw ?? '')
'request', 'path', .split(',')
]} .map((s) => s.trim())
getValueProps={(v) => ({ value: Array.isArray(v) ? v.join(',') : v })} .filter(Boolean);
getValueFromEvent={(e) => { return parts.length > 0 ? parts : ['/'];
const raw = (e?.target?.value ?? '') as string; },
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean); }}
return parts.length > 0 ? parts : ['/']; >
}} <Input placeholder="/" />
> </FormField>
<Input placeholder="/" /> <FormField
</Form.Item> label={t('pages.inbounds.form.requestHeaders')}
<Form.Item name={['streamSettings', 'tcpSettings', 'header', 'request', 'headers']}
label={t('pages.inbounds.form.requestHeaders')} >
name={[ <HeaderMapEditor mode="v2" />
'streamSettings', 'tcpSettings', 'header', </FormField>
'request', 'headers',
]}
>
<HeaderMapEditor mode="v2" />
</Form.Item>
<Form.Item <FormField
label={t('pages.inbounds.form.responseVersion')} label={t('pages.inbounds.form.responseVersion')}
name={[ name={['streamSettings', 'tcpSettings', 'header', 'response', 'version']}
'streamSettings', 'tcpSettings', 'header', >
'response', 'version', <Input placeholder="1.1" />
]} </FormField>
> <FormField
<Input placeholder="1.1" /> label={t('pages.inbounds.form.responseStatus')}
</Form.Item> name={['streamSettings', 'tcpSettings', 'header', 'response', 'status']}
<Form.Item >
label={t('pages.inbounds.form.responseStatus')} <Input placeholder="200" />
name={[ </FormField>
'streamSettings', 'tcpSettings', 'header', <FormField
'response', 'status', label={t('pages.inbounds.form.responseReason')}
]} name={['streamSettings', 'tcpSettings', 'header', 'response', 'reason']}
> >
<Input placeholder="200" /> <Input placeholder="OK" />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.responseReason')} label={t('pages.inbounds.form.responseHeaders')}
name={[ name={['streamSettings', 'tcpSettings', 'header', 'response', 'headers']}
'streamSettings', 'tcpSettings', 'header', >
'response', 'reason', <HeaderMapEditor mode="v2" />
]} </FormField>
> </>
<Input placeholder="OK" /> )}
</Form.Item> </>
<Form.Item
label={t('pages.inbounds.form.responseHeaders')}
name={[
'streamSettings', 'tcpSettings', 'header',
'response', 'headers',
]}
>
<HeaderMapEditor mode="v2" />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
); );
} }
@@ -1,225 +1,209 @@
import { useTranslation } from 'react-i18next'; 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 { DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION } from '@/schemas/primitives';
import { HappyEyeballsSchema, SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; 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'; import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
export default function SockoptForm({ export default function SockoptForm({
form,
outboundTags = [], outboundTags = [],
}: { }: {
form: FormInstance<OutboundFormValues>;
outboundTags?: string[]; outboundTags?: string[];
}) { }) {
const { t } = useTranslation(); 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 ( return (
<Form.Item shouldUpdate noStyle> <>
{() => { <Form.Item label={t('pages.xray.outboundForm.sockopts')}>
const hasSockopt = !!form.getFieldValue([ <Switch
'streamSettings', checked={hasSockopt}
'sockopt', onChange={(checked) => {
]); setValue(
const dialerProxy = (form.getFieldValue([ 'streamSettings.sockopt',
'streamSettings', checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
'sockopt', );
'dialerProxy', }}
]) ?? '') as string; />
const dialerProxyOptions = Array.from( </Form.Item>
new Set([...outboundTags, dialerProxy].filter(Boolean)), {hasSockopt && (
).map((tg) => ({ value: tg, label: tg })); <>
return ( <FormField
<> label={t('pages.inbounds.form.dialerProxy')}
<Form.Item label={t('pages.xray.outboundForm.sockopts')}> name={['streamSettings', 'sockopt', 'dialerProxy']}
<Switch tooltip={t('pages.xray.outboundForm.dialerProxyHint')}
checked={hasSockopt} >
onChange={(checked) => { <Select
form.setFieldValue( allowClear
['streamSettings', 'sockopt'], showSearch
checked ? SockoptStreamSettingsSchema.parse({}) : undefined, placeholder={t('pages.xray.outboundForm.dialerProxyPlaceholder')}
); options={dialerProxyOptions}
}} />
/> </FormField>
</Form.Item> <FormField
{hasSockopt && ( label={t('pages.xray.wireguard.domainStrategy')}
<> name={['streamSettings', 'sockopt', 'domainStrategy']}
<Form.Item >
label={t('pages.inbounds.form.dialerProxy')} <Select
name={['streamSettings', 'sockopt', 'dialerProxy']} options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
tooltip={t('pages.xray.outboundForm.dialerProxyHint')} value: v,
> label: v,
<Select }))}
allowClear />
showSearch </FormField>
placeholder={t('pages.xray.outboundForm.dialerProxyPlaceholder')} <FormField
options={dialerProxyOptions} label={t('pages.inbounds.form.addressPortStrategy')}
/> name={['streamSettings', 'sockopt', 'addressPortStrategy']}
</Form.Item> >
<Form.Item <Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
label={t('pages.xray.wireguard.domainStrategy')} </FormField>
name={['streamSettings', 'sockopt', 'domainStrategy']} <FormField
> label={t('pages.xray.outboundForm.keepAliveInterval')}
<Select name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ >
value: v, <InputNumber min={0} />
label: v, </FormField>
}))} <FormField
/> label={t('pages.inbounds.form.tcpFastOpen')}
</Form.Item> name={['streamSettings', 'sockopt', 'tcpFastOpen']}
<Form.Item valueProp="checked"
label={t('pages.inbounds.form.addressPortStrategy')} >
name={['streamSettings', 'sockopt', 'addressPortStrategy']} <Switch />
> </FormField>
<Select options={ADDRESS_PORT_STRATEGY_OPTIONS} /> <FormField
</Form.Item> label={t('pages.inbounds.form.multipathTcp')}
<Form.Item name={['streamSettings', 'sockopt', 'tcpMptcp']}
label={t('pages.xray.outboundForm.keepAliveInterval')} valueProp="checked"
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']} >
> <Switch />
<InputNumber min={0} /> </FormField>
</Form.Item> <FormField
<Form.Item label={t('pages.inbounds.form.penetrate')}
label={t('pages.inbounds.form.tcpFastOpen')} name={['streamSettings', 'sockopt', 'penetrate']}
name={['streamSettings', 'sockopt', 'tcpFastOpen']} valueProp="checked"
valuePropName="checked" >
> <Switch />
<Switch /> </FormField>
</Form.Item> <FormField
<Form.Item label={t('pages.xray.outboundForm.markFwmark')}
label={t('pages.inbounds.form.multipathTcp')} name={['streamSettings', 'sockopt', 'mark']}
name={['streamSettings', 'sockopt', 'tcpMptcp']} >
valuePropName="checked" <InputNumber min={0} />
> </FormField>
<Switch /> <FormField
</Form.Item> label={t('pages.xray.outboundForm.interface')}
<Form.Item name={['streamSettings', 'sockopt', 'interface']}
label={t('pages.inbounds.form.penetrate')} >
name={['streamSettings', 'sockopt', 'penetrate']} <Input />
valuePropName="checked" </FormField>
> <FormField
<Switch /> label="TProxy"
</Form.Item> name={['streamSettings', 'sockopt', 'tproxy']}
<Form.Item >
label={t('pages.xray.outboundForm.markFwmark')} <Select
name={['streamSettings', 'sockopt', 'mark']} options={[
> { value: 'off', label: 'off' },
<InputNumber min={0} /> { value: 'redirect', label: 'redirect' },
</Form.Item> { value: 'tproxy', label: 'tproxy' },
<Form.Item ]}
label={t('pages.xray.outboundForm.interface')} />
name={['streamSettings', 'sockopt', 'interface']} </FormField>
> <FormField
<Input /> label={t('pages.inbounds.form.tcpCongestion')}
</Form.Item> name={['streamSettings', 'sockopt', 'tcpcongestion']}
<Form.Item >
label="TProxy" <Select
name={['streamSettings', 'sockopt', 'tproxy']} options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({
> value: v,
<Select label: v,
options={[ }))}
{ value: 'off', label: 'off' }, />
{ value: 'redirect', label: 'redirect' }, </FormField>
{ value: 'tproxy', label: 'tproxy' }, <FormField
]} label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
/> name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
</Form.Item> >
<Form.Item <InputNumber min={0} />
label={t('pages.inbounds.form.tcpCongestion')} </FormField>
name={['streamSettings', 'sockopt', 'tcpcongestion']} <FormField
> label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
<Select name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({ >
value: v, <InputNumber min={0} />
label: v, </FormField>
}))} <FormField
/> label={t('pages.inbounds.form.tcpMaxSeg')}
</Form.Item> name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
<Form.Item >
label={t('pages.xray.outboundForm.tcpUserTimeoutMs')} <InputNumber min={0} />
name={['streamSettings', 'sockopt', 'tcpUserTimeout']} </FormField>
> <FormField
<InputNumber min={0} /> label={t('pages.inbounds.form.tcpWindowClamp')}
</Form.Item> name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
<Form.Item tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')} >
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']} <InputNumber min={0} />
> </FormField>
<InputNumber min={0} /> <Form.Item label="Happy Eyeballs">
</Form.Item> <Switch
<Form.Item checked={hasHe}
label={t('pages.inbounds.form.tcpMaxSeg')} onChange={(v) => {
name={['streamSettings', 'sockopt', 'tcpMaxSeg']} setValue(
> 'streamSettings.sockopt.happyEyeballs',
<InputNumber min={0} /> v ? HappyEyeballsSchema.parse({}) : undefined,
</Form.Item> );
<Form.Item }}
label={t('pages.inbounds.form.tcpWindowClamp')} />
name={['streamSettings', 'sockopt', 'tcpWindowClamp']} </Form.Item>
tooltip={t('pages.inbounds.form.tcpWindowClampHint')} {hasHe && (
> <>
<InputNumber min={0} /> <FormField
</Form.Item> label={t('pages.inbounds.form.tryDelayMs')}
<Form.Item shouldUpdate noStyle> name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
{() => { >
const he = form.getFieldValue([ <InputNumber min={0} placeholder="0 (disabled) — 250 recommended" />
'streamSettings', 'sockopt', 'happyEyeballs', </FormField>
]); <FormField
const hasHe = he != null; label={t('pages.inbounds.form.prioritizeIPv6')}
return ( name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
<> valueProp="checked"
<Form.Item label="Happy Eyeballs"> >
<Switch <Switch />
checked={hasHe} </FormField>
onChange={(v) => { <FormField
form.setFieldValue( label={t('pages.inbounds.form.interleave')}
['streamSettings', 'sockopt', 'happyEyeballs'], name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
v ? HappyEyeballsSchema.parse({}) : undefined, >
); <InputNumber min={1} />
}} </FormField>
/> <FormField
</Form.Item> label={t('pages.inbounds.form.maxConcurrentTry')}
{hasHe && ( name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
<> >
<Form.Item <InputNumber min={0} />
label={t('pages.inbounds.form.tryDelayMs')} </FormField>
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']} </>
> )}
<InputNumber min={0} placeholder="0 (disabled) — 250 recommended" /> <Controller
</Form.Item> control={control}
<Form.Item name="streamSettings.sockopt.customSockopt"
label={t('pages.inbounds.form.prioritizeIPv6')} render={({ field }) => (
name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']} <SockoptCustomField value={field.value} onChange={field.onChange} />
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.interleave')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
>
<InputNumber min={1} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.maxConcurrentTry')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
>
<InputNumber min={0} />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
<CustomSockoptList />
</>
)} )}
</> />
); </>
}} )}
</Form.Item> </>
); );
} }
@@ -1,30 +1,31 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber } from 'antd'; import { Input, InputNumber } from 'antd';
import { HeaderMapEditor } from '@/components/form'; import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function WsForm() { export default function WsForm() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Form.Item label={t('host')} name={['streamSettings', 'wsSettings', 'host']}> <FormField label={t('host')} name={['streamSettings', 'wsSettings', 'host']}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item label={t('path')} name={['streamSettings', 'wsSettings', 'path']}> <FormField label={t('path')} name={['streamSettings', 'wsSettings', 'path']}>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.heartbeatPeriod')} label={t('pages.inbounds.form.heartbeatPeriod')}
name={['streamSettings', 'wsSettings', 'heartbeatPeriod']} name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.headers')} label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'wsSettings', 'headers']} name={['streamSettings', 'wsSettings', 'headers']}
> >
<HeaderMapEditor mode="v1" /> <HeaderMapEditor mode="v1" />
</Form.Item> </FormField>
</> </>
); );
} }
@@ -1,151 +1,141 @@
import { useTranslation } from 'react-i18next'; 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 { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id'; 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 { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
import { MODE_OPTIONS } from '../outbound-form-constants'; import { MODE_OPTIONS } from '../outbound-form-constants';
interface XhttpFormProps { interface XhttpFormProps {
form: FormInstance<OutboundFormValues>;
onXmuxToggle: (checked: boolean) => void; onXmuxToggle: (checked: boolean) => void;
} }
export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) { function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
return async (value: unknown): Promise<true | string> => {
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 { 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 ( return (
<> <>
<Form.Item <FormField label={t('host')} name={['streamSettings', 'xhttpSettings', 'host']}>
label={t('host')}
name={['streamSettings', 'xhttpSettings', 'host']}
>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField label={t('path')} name={['streamSettings', 'xhttpSettings', 'path']}>
label={t('path')}
name={['streamSettings', 'xhttpSettings', 'path']}
>
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField label={t('pages.inbounds.info.mode')} name={['streamSettings', 'xhttpSettings', 'mode']}>
label={t('pages.inbounds.info.mode')}
name={['streamSettings', 'xhttpSettings', 'mode']}
>
<Select options={MODE_OPTIONS} /> <Select options={MODE_OPTIONS} />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.paddingBytes')} label={t('pages.inbounds.form.paddingBytes')}
name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']} name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
> >
<Input /> <Input />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.headers')} label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'xhttpSettings', 'headers']} name={['streamSettings', 'xhttpSettings', 'headers']}
> >
<HeaderMapEditor mode="v1" /> <HeaderMapEditor mode="v1" />
</Form.Item> </FormField>
{/* Padding obfs sub-section: gated by a Switch. {/* Padding obfs sub-section: gated by a Switch.
When on, four extra knobs (key/header/placement/ When on, four extra knobs (key/header/placement/
method) tune how Xray injects random padding to method) tune how Xray injects random padding to
disguise the post body shape. */} disguise the post body shape. */}
<Form.Item <FormField
label={t('pages.inbounds.form.paddingObfsMode')} label={t('pages.inbounds.form.paddingObfsMode')}
name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']} name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
valuePropName="checked" valueProp="checked"
> >
<Switch /> <Switch />
</Form.Item> </FormField>
<Form.Item shouldUpdate noStyle> {obfs && (
{() => { <>
const obfs = !!form.getFieldValue([ <FormField
'streamSettings', 'xhttpSettings', 'xPaddingObfsMode', label={t('pages.inbounds.form.paddingKey')}
]); name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
if (!obfs) return null; >
return ( <Input placeholder="x_padding" />
<> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.paddingKey')} label={t('pages.inbounds.form.paddingHeader')}
name={['streamSettings', 'xhttpSettings', 'xPaddingKey']} name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
> >
<Input placeholder="x_padding" /> <Input placeholder="X-Padding" />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.inbounds.form.paddingHeader')} label={t('pages.inbounds.form.paddingPlacement')}
name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']} name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
> >
<Input placeholder="X-Padding" /> <Select
</Form.Item> options={[
<Form.Item { value: '', label: 'Default (queryInHeader)' },
label={t('pages.inbounds.form.paddingPlacement')} { value: 'queryInHeader', label: 'queryInHeader' },
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']} { value: 'header', label: 'header' },
> { value: 'cookie', label: 'cookie' },
<Select { value: 'query', label: 'query' },
options={[ ]}
{ value: '', label: 'Default (queryInHeader)' }, />
{ value: 'queryInHeader', label: 'queryInHeader' }, </FormField>
{ value: 'header', label: 'header' }, <FormField
{ value: 'cookie', label: 'cookie' }, label={t('pages.inbounds.form.paddingMethod')}
{ value: 'query', label: 'query' }, name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
]} >
/> <Select
</Form.Item> options={[
<Form.Item { value: '', label: 'Default (repeat-x)' },
label={t('pages.inbounds.form.paddingMethod')} { value: 'repeat-x', label: 'repeat-x' },
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']} { value: 'tokenish', label: 'tokenish' },
> ]}
<Select />
options={[ </FormField>
{ value: '', label: 'Default (repeat-x)' }, </>
{ value: 'repeat-x', label: 'repeat-x' }, )}
{ value: 'tokenish', label: 'tokenish' },
]}
/>
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item <FormField
noStyle label={t('pages.inbounds.form.uplinkHttpMethod')}
shouldUpdate={(prev, curr) => name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
prev?.streamSettings?.xhttpSettings?.mode !==
curr?.streamSettings?.xhttpSettings?.mode
}
> >
{() => { <Select
const mode = form.getFieldValue([ placeholder="Default (POST)"
'streamSettings', 'xhttpSettings', 'mode', options={[
]); { value: '', label: 'Default (POST)' },
return ( { value: 'POST', label: 'POST' },
<Form.Item { value: 'PUT', label: 'PUT' },
label={t('pages.inbounds.form.uplinkHttpMethod')} { value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']} ]}
> />
<Select </FormField>
placeholder="Default (POST)"
options={[
{ value: '', label: 'Default (POST)' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
]}
/>
</Form.Item>
);
}}
</Form.Item>
{/* Session + sequence + uplinkData placements: {/* Session + sequence + uplinkData placements:
three orthogonal slots Xray uses to thread three orthogonal slots Xray uses to thread
request metadata through the transport request metadata through the transport
(path / header / cookie / query). Key field (path / header / cookie / query). Key field
only matters when placement is not 'path'. */} only matters when placement is not 'path'. */}
<Form.Item <FormField
label={t('pages.inbounds.form.sessionPlacement')} label={t('pages.inbounds.form.sessionPlacement')}
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']} name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
> >
@@ -159,54 +149,38 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
{ value: 'query', label: 'query' }, { value: 'query', label: 'query' },
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item shouldUpdate noStyle> {sessionPlacement && sessionPlacement !== 'path' && (
{() => { <FormField
const placement = form.getFieldValue([ label={t('pages.inbounds.form.sessionKey')}
'streamSettings', 'xhttpSettings', 'sessionIDPlacement', name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
]); >
if (!placement || placement === 'path') return null; <Input placeholder="x_session" />
return ( </FormField>
<Form.Item )}
label={t('pages.inbounds.form.sessionKey')} <FormField
name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
>
<Input placeholder="x_session" />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.sessionIDTable')} label={t('pages.inbounds.form.sessionIDTable')}
tooltip={t('pages.inbounds.form.sessionIDTableHint')} tooltip={t('pages.inbounds.form.sessionIDTableHint')}
name={['streamSettings', 'xhttpSettings', 'sessionIDTable']} name={['streamSettings', 'xhttpSettings', 'sessionIDTable']}
rules={[{ validator: validateSessionIDTable }]} rules={{ validate: antdValidatorToRhf(validateSessionIDTable) }}
> >
<AutoComplete <AutoComplete
allowClear allowClear
options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))} options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))}
placeholder="Base62" placeholder="Base62"
/> />
</Form.Item> </FormField>
<Form.Item shouldUpdate noStyle> {!!table && (
{() => { <FormField
const table = form.getFieldValue([ label={t('pages.inbounds.form.sessionIDLength')}
'streamSettings', 'xhttpSettings', 'sessionIDTable', tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
]); name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
if (!table) return null; rules={{ validate: antdValidatorToRhf(validateSessionIDLength) }}
return ( >
<Form.Item <Input placeholder="8-16" />
label={t('pages.inbounds.form.sessionIDLength')} </FormField>
tooltip={t('pages.inbounds.form.sessionIDLengthHint')} )}
name={['streamSettings', 'xhttpSettings', 'sessionIDLength']} <FormField
rules={[{ validator: validateSessionIDLength }]}
>
<Input placeholder="8-16" />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.sequencePlacement')} label={t('pages.inbounds.form.sequencePlacement')}
name={['streamSettings', 'xhttpSettings', 'seqPlacement']} name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
> >
@@ -220,168 +194,130 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
{ value: 'query', label: 'query' }, { value: 'query', label: 'query' },
]} ]}
/> />
</Form.Item> </FormField>
<Form.Item shouldUpdate noStyle> {seqPlacement && seqPlacement !== 'path' && (
{() => { <FormField
const placement = form.getFieldValue([ label={t('pages.inbounds.form.sequenceKey')}
'streamSettings', 'xhttpSettings', 'seqPlacement', name={['streamSettings', 'xhttpSettings', 'seqKey']}
]); >
if (!placement || placement === 'path') return null; <Input placeholder="x_seq" />
return ( </FormField>
<Form.Item )}
label={t('pages.inbounds.form.sequenceKey')}
name={['streamSettings', 'xhttpSettings', 'seqKey']}
>
<Input placeholder="x_seq" />
</Form.Item>
);
}}
</Form.Item>
{/* Mode-conditional sub-sections. */} {/* Mode-conditional sub-sections. */}
<Form.Item shouldUpdate noStyle> {(mode === 'packet-up' || mode === 'auto') && (
{() => { <>
const mode = form.getFieldValue([ <FormField
'streamSettings', 'xhttpSettings', 'mode', label={t('pages.xray.outboundForm.minUploadInterval')}
]); name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
if (mode !== 'packet-up' && mode !== 'auto') return null; >
return ( <Input placeholder="e.g. 50-150" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.maxUploadSizeBytes')}
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
>
<Input placeholder="1000000" />
</FormField>
<FormField
label={t('pages.inbounds.form.uplinkDataPlacement')}
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
{uplinkDataPlacement && uplinkDataPlacement !== 'body' && (
<> <>
<Form.Item <FormField
label={t('pages.xray.outboundForm.minUploadInterval')} label={t('pages.inbounds.form.uplinkDataKey')}
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']} name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
> >
<Input placeholder="e.g. 50-150" /> <Input placeholder="x_data" />
</Form.Item> </FormField>
<Form.Item <FormField
label={t('pages.xray.outboundForm.maxUploadSizeBytes')} label={t('pages.xray.outboundForm.uplinkChunkSize')}
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']} name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
> >
<Input placeholder="1000000" /> <InputNumber
</Form.Item> min={0}
<Form.Item placeholder="0 (unlimited)"
label={t('pages.inbounds.form.uplinkDataPlacement')} style={{ width: '100%' }}
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/> />
</Form.Item> </FormField>
<Form.Item shouldUpdate noStyle>
{() => {
const place = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'uplinkDataPlacement',
]);
if (!place || place === 'body') return null;
return (
<>
<Form.Item
label={t('pages.inbounds.form.uplinkDataKey')}
name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
>
<Input placeholder="x_data" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.uplinkChunkSize')}
name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
>
<InputNumber
min={0}
placeholder="0 (unlimited)"
style={{ width: '100%' }}
/>
</Form.Item>
</>
);
}}
</Form.Item>
</> </>
); )}
}} </>
</Form.Item> )}
<Form.Item shouldUpdate noStyle> {(mode === 'stream-up' || mode === 'stream-one') && (
{() => { <FormField
const mode = form.getFieldValue([ label={t('pages.xray.outboundForm.noGrpcHeader')}
'streamSettings', 'xhttpSettings', 'mode', name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
]); valueProp="checked"
if (mode !== 'stream-up' && mode !== 'stream-one') return null; >
return ( <Switch />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.noGrpcHeader')} )}
name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
valuePropName="checked"
>
<Switch />
</Form.Item>
);
}}
</Form.Item>
{/* XMUX is the connection-multiplexing layer {/* XMUX is the connection-multiplexing layer
xHTTP uses to fan out parallel requests over xHTTP uses to fan out parallel requests over
a small pool of upstream connections. UI-only a small pool of upstream connections. UI-only
toggle (enableXmux) hides the 6 nested knobs toggle (enableXmux) hides the 6 nested knobs
when off. */} when off. */}
<Form.Item <FormField
label="XMUX" label="XMUX"
name={['streamSettings', 'xhttpSettings', 'enableXmux']} name={['streamSettings', 'xhttpSettings', 'enableXmux']}
valuePropName="checked" valueProp="checked"
onAfterChange={(v) => onXmuxToggle(v as boolean)}
> >
<Switch onChange={onXmuxToggle} /> <Switch />
</Form.Item> </FormField>
<Form.Item shouldUpdate noStyle> {enableXmux && (
{() => { <>
if (!form.getFieldValue([ <FormField
'streamSettings', 'xhttpSettings', 'enableXmux', label={t('pages.xray.outboundForm.maxConcurrency')}
])) return null; name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
return ( >
<> <Input placeholder="16-32" />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.maxConcurrency')} <FormField
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']} label={t('pages.xray.outboundForm.maxConnections')}
> name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
<Input placeholder="16-32" /> >
</Form.Item> <Input placeholder="0" />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.maxConnections')} <FormField
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']} label={t('pages.xray.outboundForm.maxReuseTimes')}
> name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
<Input placeholder="0" /> >
</Form.Item> <Input />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.maxReuseTimes')} <FormField
name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']} label={t('pages.xray.outboundForm.maxRequestTimes')}
> name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
<Input /> >
</Form.Item> <Input placeholder="600-900" />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.maxRequestTimes')} <FormField
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']} label={t('pages.xray.outboundForm.maxReusableSecs')}
> name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
<Input placeholder="600-900" /> >
</Form.Item> <Input placeholder="1800-3000" />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.maxReusableSecs')} <FormField
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']} label={t('pages.xray.outboundForm.keepAlivePeriod')}
> name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
<Input placeholder="1800-3000" /> >
</Form.Item> <InputNumber min={0} style={{ width: '100%' }} />
<Form.Item </FormField>
label={t('pages.xray.outboundForm.keepAlivePeriod')} </>
name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']} )}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</>
);
}}
</Form.Item>
</> </>
); );
} }
+55 -50
View File
@@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd'; import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
import { LoginOutlined, SaveOutlined } from '@ant-design/icons'; import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
import { FormField } from '@/components/form/rhf';
import './NordModal.css'; import './NordModal.css';
interface NordModalProps { interface NordModalProps {
@@ -44,6 +46,22 @@ interface NordServer {
cityName?: string; 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 { function loadColor(load: number): string {
if (load < 30) return 'green'; if (load < 30) return 'green';
if (load < 70) return 'orange'; if (load < 70) return 'orange';
@@ -63,14 +81,12 @@ export default function NordModal({
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [nordData, setNordData] = useState<NordData | null>(null); const [nordData, setNordData] = useState<NordData | null>(null);
const [token, setToken] = useState('');
const [manualKey, setManualKey] = useState('');
const [countries, setCountries] = useState<Country[]>([]); const [countries, setCountries] = useState<Country[]>([]);
const [cities, setCities] = useState<City[]>([]); const [cities, setCities] = useState<City[]>([]);
const [servers, setServers] = useState<NordServer[]>([]); const [servers, setServers] = useState<NordServer[]>([]);
const [countryId, setCountryId] = useState<number | null>(null); const methods = useForm<NordFormValues>({ defaultValues: EMPTY });
const [cityId, setCityId] = useState<number | null>(null); const cityId = useWatch({ control: methods.control, name: 'cityId' });
const [serverId, setServerId] = useState<number | null>(null); const serverId = useWatch({ control: methods.control, name: 'serverId' });
const nordOutboundIndex = useMemo(() => { const nordOutboundIndex = useMemo(() => {
const list = templateSettings?.outbounds; const list = templateSettings?.outbounds;
@@ -84,8 +100,8 @@ export default function NordModal({
}, [cityId, servers]); }, [cityId, servers]);
useEffect(() => { useEffect(() => {
setServerId(filteredServers.length > 0 ? filteredServers[0].id : null); methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
}, [filteredServers]); }, [filteredServers, methods]);
const fetchCountries = useCallback(async () => { const fetchCountries = useCallback(async () => {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries'); const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries');
@@ -113,7 +129,7 @@ export default function NordModal({
async function login() { async function login() {
setLoading(true); setLoading(true);
try { try {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token }); const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token: methods.getValues('token') });
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj)); setNordData(JSON.parse(msg.obj));
await fetchCountries(); await fetchCountries();
@@ -126,7 +142,7 @@ export default function NordModal({
async function saveKey() { async function saveKey() {
setLoading(true); setLoading(true);
try { try {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: manualKey }); const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: methods.getValues('manualKey') });
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj)); setNordData(JSON.parse(msg.obj));
await fetchCountries(); await fetchCountries();
@@ -144,14 +160,10 @@ export default function NordModal({
onRemoveOutbound(nordOutboundIndex); onRemoveOutbound(nordOutboundIndex);
onRemoveRoutingRules({ prefix: 'nord-' }); onRemoveRoutingRules({ prefix: 'nord-' });
setNordData(null); setNordData(null);
setToken(''); methods.reset(EMPTY);
setManualKey('');
setCountries([]); setCountries([]);
setCities([]); setCities([]);
setServers([]); setServers([]);
setCountryId(null);
setCityId(null);
setServerId(null);
} }
} finally { } finally {
setLoading(false); setLoading(false);
@@ -159,12 +171,11 @@ export default function NordModal({
} }
async function fetchServers(newCountryId: number) { async function fetchServers(newCountryId: number) {
setCountryId(newCountryId);
setLoading(true); setLoading(true);
setServers([]); setServers([]);
setCities([]); setCities([]);
setServerId(null); methods.setValue('serverId', null);
setCityId(null); methods.setValue('cityId', null);
try { try {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId }); const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId });
if (!msg?.success || !msg.obj) return; if (!msg?.success || !msg.obj) return;
@@ -194,7 +205,8 @@ export default function NordModal({
} }
function buildNordOutbound(): Record<string, unknown> | null { function buildNordOutbound(): Record<string, unknown> | 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; if (!server) return null;
const tech = server.technologies?.find((tt) => tt.id === 35); const tech = server.technologies?.find((tt) => tt.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value; const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
@@ -244,6 +256,7 @@ export default function NordModal({
<> <>
{messageContextHolder} {messageContextHolder}
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}> <Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
<FormProvider {...methods}>
{nordData == null ? ( {nordData == null ? (
<Tabs <Tabs
defaultActiveKey="token" defaultActiveKey="token"
@@ -258,16 +271,12 @@ export default function NordModal({
wrapperCol={{ md: { span: 18 } }} wrapperCol={{ md: { span: 18 } }}
className="mt-20" className="mt-20"
> >
<Form.Item label={t('pages.xray.nord.accessToken')}> <FormField name="token" label={t('pages.xray.nord.accessToken')}>
<Input <Input placeholder={t('pages.xray.nord.accessToken')} />
value={token} </FormField>
placeholder={t('pages.xray.nord.accessToken')} <Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
onChange={(e) => setToken(e.target.value)} {t('login')}
/> </Button>
<Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
{t('login')}
</Button>
</Form.Item>
</Form> </Form>
), ),
}, },
@@ -281,16 +290,12 @@ export default function NordModal({
wrapperCol={{ md: { span: 18 } }} wrapperCol={{ md: { span: 18 } }}
className="mt-20" className="mt-20"
> >
<Form.Item label={t('pages.xray.nord.privateKey')}> <FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
<Input <Input placeholder={t('pages.xray.nord.privateKey')} />
value={manualKey} </FormField>
placeholder={t('pages.xray.nord.privateKey')} <Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
onChange={(e) => setManualKey(e.target.value)} {t('save')}
/> </Button>
<Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
{t('save')}
</Button>
</Form.Item>
</Form> </Form>
), ),
}, },
@@ -320,35 +325,34 @@ export default function NordModal({
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider> <Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 18 } }} className="mt-10"> <Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 18 } }} className="mt-10">
<Form.Item label={t('pages.xray.outbound.country')}> <FormField
name="countryId"
label={t('pages.xray.outbound.country')}
transform={{ input: (v) => v ?? undefined }}
onAfterChange={(v) => fetchServers(v as number)}
>
<Select <Select
value={countryId ?? undefined}
showSearch={{ optionFilterProp: 'label' }} showSearch={{ optionFilterProp: 'label' }}
onChange={(v) => fetchServers(v)}
options={countries.map((c) => ({ options={countries.map((c) => ({
value: c.id, value: c.id,
label: `${c.name} (${c.code})`, label: `${c.name} (${c.code})`,
}))} }))}
/> />
</Form.Item> </FormField>
{cities.length > 0 && ( {cities.length > 0 && (
<Form.Item label={t('pages.xray.outbound.city')}> <FormField name="cityId" label={t('pages.xray.outbound.city')}>
<Select <Select
value={cityId}
showSearch={{ optionFilterProp: 'label' }} showSearch={{ optionFilterProp: 'label' }}
onChange={setCityId}
options={[{ value: null, label: t('pages.xray.outbound.allCities') }, ...cities.map((c) => ({ value: c.id, label: c.name }))]} options={[{ value: null, label: t('pages.xray.outbound.allCities') }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
/> />
</Form.Item> </FormField>
)} )}
{filteredServers.length > 0 && ( {filteredServers.length > 0 && (
<Form.Item label={t('pages.xray.outbound.server')}> <FormField name="serverId" label={t('pages.xray.outbound.server')}>
<Select <Select
value={serverId}
showSearch={{ optionFilterProp: 'label' }} showSearch={{ optionFilterProp: 'label' }}
onChange={setServerId}
options={filteredServers.map((s) => ({ options={filteredServers.map((s) => ({
value: s.id, value: s.id,
label: `${s.cityName} ${s.name} ${s.hostname}`, label: `${s.cityName} ${s.name} ${s.hostname}`,
@@ -364,7 +368,7 @@ export default function NordModal({
), ),
}))} }))}
/> />
</Form.Item> </FormField>
)} )}
</Form> </Form>
@@ -392,6 +396,7 @@ export default function NordModal({
)} )}
</> </>
)} )}
</FormProvider>
</Modal> </Modal>
</> </>
); );
+51 -43
View File
@@ -12,8 +12,10 @@ import {
Tag, Tag,
} from 'antd'; } from 'antd';
import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'; 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 { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
import { FormField } from '@/components/form/rhf';
import './WarpModal.css'; import './WarpModal.css';
interface WarpModalProps { 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[] { function addressesFor(addrs: { v4?: string; v6?: string }): string[] {
const out: string[] = []; const out: string[] = [];
if (addrs.v4) out.push(`${addrs.v4}/32`); if (addrs.v4) out.push(`${addrs.v4}/32`);
@@ -79,10 +88,10 @@ export default function WarpModal({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [warpData, setWarpData] = useState<WarpData | null>(null); const [warpData, setWarpData] = useState<WarpData | null>(null);
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null); const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
const [warpPlus, setWarpPlus] = useState('');
const [updateInterval, setUpdateInterval] = useState<number>(0);
const [licenseError, setLicenseError] = useState(''); const [licenseError, setLicenseError] = useState('');
const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null); const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
const methods = useForm<WarpFormValues>({ defaultValues: EMPTY });
const warpPlusValue = useWatch({ control: methods.control, name: 'warpPlus' }) ?? '';
const warpOutboundIndex = useMemo(() => { const warpOutboundIndex = useMemo(() => {
const list = templateSettings?.outbounds; const list = templateSettings?.outbounds;
@@ -132,12 +141,12 @@ export default function WarpModal({
} }
const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all'); const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all');
if (settingMsg?.success && settingMsg.obj) { if (settingMsg?.success && settingMsg.obj) {
setUpdateInterval(Number(settingMsg.obj.warpUpdateInterval) || 0); methods.setValue('updateInterval', Number(settingMsg.obj.warpUpdateInterval) || 0);
} }
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [methods]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -202,7 +211,7 @@ export default function WarpModal({
async function saveInterval() { async function saveInterval() {
setLoading(true); setLoading(true);
try { 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) { if (msg?.success) {
messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully')); messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
} }
@@ -212,15 +221,16 @@ export default function WarpModal({
} }
async function updateLicense() { async function updateLicense() {
if (warpPlus.length < 26) return; const licenseValue = methods.getValues('warpPlus');
if (licenseValue.length < 26) return;
setLoading(true); setLoading(true);
setLicenseError(''); setLicenseError('');
try { try {
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: warpPlus }); const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: licenseValue });
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
setWarpData(JSON.parse(msg.obj)); setWarpData(JSON.parse(msg.obj));
setWarpConfig(null); setWarpConfig(null);
setWarpPlus(''); methods.setValue('warpPlus', '');
} else { } else {
setLicenseError(msg?.msg || t('pages.xray.warp.licenseError')); setLicenseError(msg?.msg || t('pages.xray.warp.licenseError'));
} }
@@ -266,6 +276,7 @@ export default function WarpModal({
<> <>
{messageContextHolder} {messageContextHolder}
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}> <Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
<FormProvider {...methods}>
{!hasWarp ? ( {!hasWarp ? (
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}> <Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
{t('pages.xray.warp.createAccount')} {t('pages.xray.warp.createAccount')}
@@ -307,29 +318,26 @@ export default function WarpModal({
label: t('pages.xray.warp.licenseKeyLabel'), label: t('pages.xray.warp.licenseKeyLabel'),
children: ( children: (
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 14 } }}> <Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label={t('pages.xray.warp.key')}> <FormField
<Input name="warpPlus"
value={warpPlus} label={t('pages.xray.warp.key')}
placeholder={t('pages.xray.warp.keyPlaceholder')} onAfterChange={() => setLicenseError('')}
onChange={(e) => { >
setWarpPlus(e.target.value); <Input placeholder={t('pages.xray.warp.keyPlaceholder')} />
setLicenseError(''); </FormField>
}} <div className="license-actions mt-8">
/> <Button
<div className="license-actions mt-8"> type="primary"
<Button disabled={warpPlusValue.length < 26}
type="primary" loading={loading}
disabled={warpPlus.length < 26} onClick={updateLicense}
loading={loading} >
onClick={updateLicense} {t('update')}
> </Button>
{t('update')} {licenseError && (
</Button> <Alert title={licenseError} type="error" showIcon className="license-error" />
{licenseError && ( )}
<Alert title={licenseError} type="error" showIcon className="license-error" /> </div>
)}
</div>
</Form.Item>
</Form> </Form>
), ),
}, },
@@ -338,18 +346,17 @@ export default function WarpModal({
label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'), label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
children: ( children: (
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}> <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}>
<Form.Item label={t('pages.xray.warp.intervalDays', 'Interval (Days)')} <FormField
tooltip={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}> name="updateInterval"
<Input label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
type="number" tooltip={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}
min={0} transform={{ output: (v) => Number(v) }}
value={updateInterval} >
onChange={(e) => setUpdateInterval(Number(e.target.value))} <Input type="number" min={0} />
/> </FormField>
<Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}> <Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
{t('save', 'Save')} {t('save', 'Save')}
</Button> </Button>
</Form.Item>
</Form> </Form>
), ),
}, },
@@ -431,6 +438,7 @@ export default function WarpModal({
)} )}
</> </>
)} )}
</FormProvider>
</Modal> </Modal>
</> </>
); );
+150 -159
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd'; import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd';
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { InputAddon } from '@/components/ui'; import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
import { useInboundOptions } from '@/api/queries/useInboundOptions'; import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray'; import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers'; import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
@@ -36,9 +38,7 @@ interface RuleFormModalProps {
onConfirm: (rule: Record<string, unknown>) => void; onConfirm: (rule: Record<string, unknown>) => void;
} }
type FormState = RuleFormValues; const initialForm = (): RuleFormValues => ({
const initialForm = (): FormState => ({
enabled: true, enabled: true,
domain: '', domain: '',
ip: '', ip: '',
@@ -73,7 +73,7 @@ export default function RuleFormModal({
onConfirm, onConfirm,
}: RuleFormModalProps) { }: RuleFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form, setForm] = useState<FormState>(initialForm); const methods = useForm<RuleFormValues>({ defaultValues: initialForm() });
const isEdit = rule != null; const isEdit = rule != null;
const { data: inboundOptions } = useInboundOptions(); const { data: inboundOptions } = useInboundOptions();
@@ -82,7 +82,7 @@ export default function RuleFormModal({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (rule) { if (rule) {
setForm({ methods.reset({
enabled: rule.enabled !== false, enabled: rule.enabled !== false,
domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '', domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '', ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
@@ -99,15 +99,14 @@ export default function RuleFormModal({
balancerTag: rule.balancerTag || '', balancerTag: rule.balancerTag || '',
}); });
} else { } else {
setForm(initialForm()); methods.reset(initialForm());
} }
}, [open, rule]); }, [open, rule, methods]);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => const attrs = useWatch({ control: methods.control, name: 'attrs' }) ?? [];
setForm((prev) => ({ ...prev, [key]: value }));
function submit() { function submit() {
const validated = RuleFormSchema.safeParse(form); const validated = RuleFormSchema.safeParse(methods.getValues());
if (!validated.success) return; if (!validated.success) return;
const v = validated.data; const v = validated.data;
const built: Record<string, unknown> = { const built: Record<string, unknown> = {
@@ -154,167 +153,159 @@ export default function RuleFormModal({
onOk={submit} onOk={submit}
onCancel={onClose} onCancel={onClose}
> >
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}> <FormProvider {...methods}>
<Form.Item label={t('enable')}> <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Switch <FormField name="enabled" label={t('enable')} valueProp="checked">
checked={form.enabled} <Switch disabled={isApiRule(rule ?? {})} />
onChange={(checked) => update('enabled', checked)} </FormField>
disabled={isApiRule(rule ?? {})}
/>
</Form.Item>
<Form.Item <FormField
label={ name="sourceIP"
<Tooltip title={t('pages.xray.rules.useComma')}> label={
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" /> <Tooltip title={t('pages.xray.rules.useComma')}>
</Tooltip> {t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
} </Tooltip>
> }
<Input value={form.sourceIP} onChange={(e) => update('sourceIP', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" /> >
</Form.Item> <Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</FormField>
<Form.Item <FormField
label={ name="sourcePort"
<Tooltip title={t('pages.xray.rules.useComma')}> label={
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" /> <Tooltip title={t('pages.xray.rules.useComma')}>
</Tooltip> {t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
} </Tooltip>
> }
<Input value={form.sourcePort} onChange={(e) => update('sourcePort', e.target.value)} placeholder="53,443,1000-2000" /> >
</Form.Item> <Input placeholder="53,443,1000-2000" />
</FormField>
<Form.Item <FormField
label={ name="vlessRoute"
<Tooltip title={t('pages.xray.rules.useComma')}> label={
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" /> <Tooltip title={t('pages.xray.rules.useComma')}>
</Tooltip> {t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
} </Tooltip>
> }
<Input value={form.vlessRoute} onChange={(e) => update('vlessRoute', e.target.value)} placeholder="53,443,1000-2000" /> >
</Form.Item> <Input placeholder="53,443,1000-2000" />
</FormField>
<Form.Item label={t('pages.inbounds.network')}> <FormField name="network" label={t('pages.inbounds.network')}>
<Select <Select options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))} />
value={form.network} </FormField>
onChange={(v) => update('network', v)}
options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.protocol')}> <FormField name="protocol" label={t('pages.inbounds.protocol')}>
<Select <Select mode="multiple" options={PROTOCOLS.map((p) => ({ value: p, label: p }))} />
mode="multiple" </FormField>
value={form.protocol}
onChange={(v) => update('protocol', v)}
options={PROTOCOLS.map((p) => ({ value: p, label: p }))}
/>
</Form.Item>
<Form.Item label={t('pages.xray.ruleForm.attributes')}> <Form.Item label={t('pages.xray.ruleForm.attributes')}>
<Button size="small" aria-label={t('add')} icon={<PlusOutlined />} onClick={() => update('attrs', [...form.attrs, ['', '']])} /> <Button
</Form.Item> size="small"
<Form.Item wrapperCol={{ span: 24 }}> aria-label={t('add')}
{form.attrs.map((attr, idx) => ( icon={<PlusOutlined />}
<Space.Compact key={idx} block className="mb-8"> onClick={() => methods.setValue('attrs', [...attrs, ['', ''] as [string, string]])}
<InputAddon>{`${idx + 1}`}</InputAddon> />
<Input </Form.Item>
value={attr[0]} <Form.Item wrapperCol={{ span: 24 }}>
aria-label={t('pages.nodes.name')} {attrs.map((attr, idx) => (
placeholder={t('pages.nodes.name')} <Space.Compact key={idx} block className="mb-8">
onChange={(e) => { <InputAddon>{`${idx + 1}`}</InputAddon>
const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a)); <Input
update('attrs', next); value={attr[0]}
}} aria-label={t('pages.nodes.name')}
/> placeholder={t('pages.nodes.name')}
<Input onChange={(e) => {
value={attr[1]} const next = attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
aria-label={t('pages.xray.ruleForm.value')} methods.setValue('attrs', next);
placeholder={t('pages.xray.ruleForm.value')} }}
onChange={(e) => { />
const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a)); <Input
update('attrs', next); value={attr[1]}
}} aria-label={t('pages.xray.ruleForm.value')}
/> placeholder={t('pages.xray.ruleForm.value')}
<Button onChange={(e) => {
aria-label={t('remove')} const next = attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
icon={<MinusOutlined />} methods.setValue('attrs', next);
onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))} }}
/> />
</Space.Compact> <Button
))} aria-label={t('remove')}
</Form.Item> icon={<MinusOutlined />}
onClick={() => methods.setValue('attrs', attrs.filter((_, i) => i !== idx))}
/>
</Space.Compact>
))}
</Form.Item>
<Form.Item <FormField
label={ name="ip"
<Tooltip title={t('pages.xray.rules.useComma')}> label={
IP <QuestionCircleOutlined aria-hidden="true" /> <Tooltip title={t('pages.xray.rules.useComma')}>
</Tooltip> IP <QuestionCircleOutlined aria-hidden="true" />
} </Tooltip>
> }
<Input value={form.ip} onChange={(e) => update('ip', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" /> >
</Form.Item> <Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</FormField>
<Form.Item <FormField
label={ name="domain"
<Tooltip title={t('pages.xray.rules.useComma')}> label={
{t('domainName')} <QuestionCircleOutlined aria-hidden="true" /> <Tooltip title={t('pages.xray.rules.useComma')}>
</Tooltip> {t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
} </Tooltip>
> }
<Input value={form.domain} onChange={(e) => update('domain', e.target.value)} placeholder="google.com, geosite:cn" /> >
</Form.Item> <Input placeholder="google.com, geosite:cn" />
</FormField>
<Form.Item <FormField
label={ name="user"
<Tooltip title={t('pages.xray.rules.useComma')}> label={
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" /> <Tooltip title={t('pages.xray.rules.useComma')}>
</Tooltip> {t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
} </Tooltip>
> }
<Input value={form.user} onChange={(e) => update('user', e.target.value)} placeholder="email address" /> >
</Form.Item> <Input placeholder="email address" />
</FormField>
<Form.Item <FormField
label={ name="port"
<Tooltip title={t('pages.xray.rules.useComma')}> label={
{t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" /> <Tooltip title={t('pages.xray.rules.useComma')}>
</Tooltip> {t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
} </Tooltip>
> }
<Input value={form.port} onChange={(e) => update('port', e.target.value)} placeholder="53,443,1000-2000" /> >
</Form.Item> <Input placeholder="53,443,1000-2000" />
</FormField>
<Form.Item label={t('pages.xray.ruleForm.inboundTags')}> <FormField name="inboundTag" label={t('pages.xray.ruleForm.inboundTags')}>
<Select <Select
mode="multiple" mode="multiple"
value={form.inboundTag} options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
onChange={(v) => update('inboundTag', v)} />
options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))} </FormField>
/>
</Form.Item>
<Form.Item label={t('pages.xray.ruleForm.outboundTag')}> <FormField name="outboundTag" label={t('pages.xray.ruleForm.outboundTag')}>
<Select <Select options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
value={form.outboundTag} </FormField>
onChange={(v) => update('outboundTag', v)}
options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
/>
</Form.Item>
<Form.Item <FormField
label={ name="balancerTag"
<Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}> label={
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" /> <Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
</Tooltip> {t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
} </Tooltip>
> }
<Select >
value={form.balancerTag} <Select options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
onChange={(v) => update('balancerTag', v)} </FormField>
options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} </Form>
/> </FormProvider>
</Form.Item>
</Form>
</Modal> </Modal>
); );
} }
+60
View File
@@ -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 } });
});
});
+29 -27
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { Form, type FormInstance } from 'antd'; import { Form } from 'antd';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { import {
GrpcForm, GrpcForm,
@@ -17,61 +18,62 @@ import { renderWithProviders, fieldLabels } from './test-utils';
function FormHarness({ function FormHarness({
children, children,
initialValues, defaultValues,
}: { }: {
children: (form: FormInstance<InboundFormValues>) => ReactNode; children: ReactNode;
initialValues?: Record<string, unknown>; defaultValues?: Record<string, unknown>;
}) { }) {
const [form] = Form.useForm<InboundFormValues>(); const methods = useForm<InboundFormValues>({ defaultValues: defaultValues as never });
return <Form form={form} initialValues={initialValues}>{children(form)}</Form>; return (
<FormProvider {...methods}>
<Form>{children}</Form>
</FormProvider>
);
} }
function renderInForm( function renderInForm(node: ReactNode, defaultValues?: Record<string, unknown>) {
node: (form: FormInstance<InboundFormValues>) => ReactNode, return renderWithProviders(<FormHarness defaultValues={defaultValues}>{node}</FormHarness>);
initialValues?: Record<string, unknown>,
) {
return renderWithProviders(<FormHarness initialValues={initialValues}>{node}</FormHarness>);
} }
const noop = () => {}; const noop = () => {};
describe('inbound transport forms', () => { describe('inbound transport forms', () => {
it('RawForm field structure is stable', () => { it('RawForm field structure is stable', () => {
renderInForm(() => <RawForm />); renderInForm(<RawForm />);
expect(fieldLabels()).toMatchSnapshot(); expect(fieldLabels()).toMatchSnapshot();
}); });
it('WsForm field structure is stable', () => { it('WsForm field structure is stable', () => {
renderInForm(() => <WsForm />); renderInForm(<WsForm />);
expect(fieldLabels()).toMatchSnapshot(); expect(fieldLabels()).toMatchSnapshot();
}); });
it('GrpcForm field structure is stable', () => { it('GrpcForm field structure is stable', () => {
renderInForm(() => <GrpcForm />); renderInForm(<GrpcForm />);
expect(fieldLabels()).toMatchSnapshot(); expect(fieldLabels()).toMatchSnapshot();
}); });
it('KcpForm field structure is stable', () => { it('KcpForm field structure is stable', () => {
renderInForm(() => <KcpForm />); renderInForm(<KcpForm />);
expect(fieldLabels()).toMatchSnapshot(); expect(fieldLabels()).toMatchSnapshot();
}); });
it('HttpUpgradeForm field structure is stable', () => { it('HttpUpgradeForm field structure is stable', () => {
renderInForm(() => <HttpUpgradeForm />); renderInForm(<HttpUpgradeForm />);
expect(fieldLabels()).toMatchSnapshot(); expect(fieldLabels()).toMatchSnapshot();
}); });
it('XhttpForm field structure is stable', () => { it('XhttpForm field structure is stable', () => {
renderInForm((form) => <XhttpForm form={form} />); renderInForm(<XhttpForm />);
expect(fieldLabels()).toMatchSnapshot(); expect(fieldLabels()).toMatchSnapshot();
}); });
it('SockoptForm field structure is stable (server-side fields only)', () => { it('SockoptForm field structure is stable (server-side fields only)', () => {
// The inbound sockopt form shows only server/listening-side fields; /* The inbound sockopt form shows only server/listening-side fields;
// outbound-only fields (dialerProxy, domainStrategy, interface, outbound-only fields (dialerProxy, domainStrategy, interface,
// addressPortStrategy, happyEyeballs, tcpMptcp) live in the outbound form. addressPortStrategy, happyEyeballs, tcpMptcp) live in the outbound form. */
renderInForm( renderInForm(
() => <SockoptForm toggleSockopt={noop} network="tcp" />, <SockoptForm toggleSockopt={noop} network="tcp" />,
{ streamSettings: { sockopt: { mark: 0 } } }, { streamSettings: { sockopt: { mark: 0 } } },
); );
expect(fieldLabels()).toMatchSnapshot(); expect(fieldLabels()).toMatchSnapshot();
@@ -80,7 +82,7 @@ describe('inbound transport forms', () => {
describe('inbound security forms', () => { describe('inbound security forms', () => {
it('TlsForm field structure is stable', () => { it('TlsForm field structure is stable', () => {
renderInForm(() => ( renderInForm(
<TlsForm <TlsForm
saving={false} saving={false}
setCertFromPanel={noop} setCertFromPanel={noop}
@@ -89,13 +91,13 @@ describe('inbound security forms', () => {
pinFromRemote={noop} pinFromRemote={noop}
getNewEchCert={noop} getNewEchCert={noop}
clearEchCert={noop} clearEchCert={noop}
/> />,
)); );
expect(fieldLabels()).toMatchSnapshot(); expect(fieldLabels()).toMatchSnapshot();
}); });
it('RealityForm field structure is stable', () => { it('RealityForm field structure is stable', () => {
renderInForm(() => ( renderInForm(
<RealityForm <RealityForm
saving={false} saving={false}
scanning={false} scanning={false}
@@ -109,8 +111,8 @@ describe('inbound security forms', () => {
clearRealityKeypair={noop} clearRealityKeypair={noop}
genMldsa65={noop} genMldsa65={noop}
clearMldsa65={noop} clearMldsa65={noop}
/> />,
)); );
expect(fieldLabels()).toMatchSnapshot(); expect(fieldLabels()).toMatchSnapshot();
}); });
}); });
+7
View File
@@ -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 })),
];
+5
View File
@@ -0,0 +1,5 @@
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
+94
View File
@@ -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<typeof Schema>;
function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) {
const methods = useZodForm(Schema, {
defaultValues: { email: '', enabled: false, slug: '', bytes: 2 * GB },
});
return (
<FormProvider {...methods}>
<Form layout="vertical">
<FormField name="email" label="Email">
<Input aria-label="email" />
</FormField>
<FormField name="enabled" label="Enabled" valueProp="checked">
<Switch aria-label="enabled" />
</FormField>
<FormField name="slug" label="Slug" transform={{ output: (v) => String(v).toLowerCase() }}>
<Input aria-label="slug" />
</FormField>
<FormField
name="bytes"
label="Traffic"
transform={{
input: (v) => (typeof v === 'number' ? v / GB : v),
output: (v) => (typeof v === 'number' ? v * GB : 0),
}}
>
<InputNumber aria-label="bytes" />
</FormField>
<button type="button" onClick={methods.handleSubmit(onSubmit)}>Save</button>
</Form>
</FormProvider>
);
}
describe('FormField', () => {
it('submits values normalized from the Ant Design inputs', async () => {
const onSubmit = vi.fn();
render(<Harness onSubmit={onSubmit} />);
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(<Harness onSubmit={onSubmit} />);
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(<Harness onSubmit={onSubmit} />);
fireEvent.click(screen.getByText('Save'));
await screen.findByText('email-required');
expect(onSubmit).not.toHaveBeenCalled();
});
});
+7
View File
@@ -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());
-15
View File
@@ -1,15 +0,0 @@
import type { Rule } from 'antd/es/form';
import type { TFunction } from 'i18next';
import type { z } from 'zod';
export function antdRule<T extends z.ZodType>(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 }));
},
};
}
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
name: 'unit', name: 'unit',
include: ['src/test/**/*.test.ts'], include: ['src/test/**/*.test.ts'],
environment: 'node', environment: 'node',
setupFiles: ['./src/test/setup.ts'], setupFiles: ['./src/test/setup.ts', './src/test/setup.msw.ts'],
}, },
}, },
{ {