diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index ee6522251..a050de5d8 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -127,6 +127,42 @@ function isValidShareAddrInput(value: string): boolean { return SHARE_ADDR_HOSTNAME_RE.test(v); } +interface RhfValidationIssue { + path: PropertyKey[]; + message: string; +} + +function firstRhfValidationIssue( + value: unknown, + path: PropertyKey[] = [], +): RhfValidationIssue | null { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + // `type` is what marks a react-hook-form leaf FieldError; anything else is a group. + if ('type' in record) { + return { path, message: typeof record.message === 'string' ? record.message : '' }; + } + for (const key of Object.keys(record)) { + const issue = firstRhfValidationIssue(record[key], [...path, key]); + if (issue) return issue; + } + return null; +} + +function tabForValidationPath(path: PropertyKey[]): string { + if (path[0] === 'settings') return 'protocol'; + if (path[0] === 'sniffing') return 'sniffing'; + if (path[0] === 'streamSettings') { + if ( + path[1] === 'security' + || path[1] === 'realitySettings' + || path[1] === 'tlsSettings' + ) return 'security'; + return 'stream'; + } + return 'basic'; +} + interface InboundFormModalProps { open: boolean; onClose: () => void; @@ -195,6 +231,7 @@ export default function InboundFormModal({ const [saving, setSaving] = useState(false); const [scanning, setScanning] = useState(false); const [scanResult, setScanResult] = useState(null); + const [activeTab, setActiveTab] = useState('basic'); const { fallbacks, fallbackChildOptions, @@ -356,6 +393,7 @@ export default function InboundFormModal({ : buildAddModeValues(); methods.reset(initial); setScanResult(null); + setActiveTab('basic'); const initialTag = (initial.tag ?? '') as string; autoTagRef.current = isAutoInboundTag(initialTag, { port: initial.port ?? 0, @@ -460,8 +498,7 @@ export default function InboundFormModal({ /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [mode, methods]); - const submit = async () => { - if (!(await methods.trigger())) return; + const saveValues = async () => { /* * getValues() returns the entire form store, including settings.clients and * settings.fallbacks which have no bound field (clients are managed via the @@ -503,6 +540,17 @@ export default function InboundFormModal({ } }; + /* + * Field errors render inline, but every tab is force-rendered, so an error on + * a hidden tab looks like a dead Save button — jump to it and say what broke. + */ + const submit = methods.handleSubmit(saveValues, (errors) => { + const issue = firstRhfValidationIssue(errors); + if (!issue) return; + setActiveTab(tabForValidationPath(issue.path)); + messageApi.error(formatInboundIssue(issue, methods.getValues(), t)); + }); + const title = mode === 'edit' ? t('pages.inbounds.modifyInbound') : t('pages.inbounds.addInbound'); @@ -961,7 +1009,7 @@ export default function InboundFormModal({ wrapperCol={{ sm: { span: 14 } }} labelWrap > - ({ messageError: vi.fn() })); + +vi.mock('antd', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + message: { + ...actual.message, + useMessage: () => [{ error: messageError }, null], + }, + }; +}); + function renderModal() { return renderWithProviders( {}} + onSaved={() => {}} + />, + ); +} + describe('InboundFormModal', () => { it('renders add mode without crashing', () => { renderModal(); @@ -141,4 +212,37 @@ describe('InboundFormModal', () => { expect(strategyItem('Node address')).toBeTruthy(); expect(strategyItem('Inbound listen')).toBeFalsy(); }); + + it('surfaces a Reality validation error and switches to its tab', async () => { + const post = vi.mocked(HttpUtil.post); + post.mockClear(); + messageError.mockClear(); + renderCloneLikeEdit(cloneLikeVlessInbound('example.com')); + + fireEvent.click(primaryButton()); + + await waitFor(() => { + const securityTab = screen.getByRole('tab', { name: 'Security' }); + expect(securityTab.getAttribute('aria-selected')).toBe('true'); + }); + expect(messageError).toHaveBeenCalledWith( + expect.stringContaining('REALITY target must include a port'), + ); + expect(post).not.toHaveBeenCalled(); + }); + + it('submits a valid clone-like Reality inbound', async () => { + const post = vi.mocked(HttpUtil.post); + post.mockClear(); + renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443')); + + fireEvent.click(primaryButton()); + + await waitFor(() => { + expect(post).toHaveBeenCalledWith( + '/panel/api/inbounds/update/42', + expect.objectContaining({ enable: false, port: 41234, protocol: 'vless' }), + ); + }); + }); });