From a3012daa8f370804ceb7022648562513ede0b78b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 25 May 2026 17:45:02 +0200 Subject: [PATCH] feat(frontend): migrate five secondary form modals to Zod schemas Apply the schema + safeParse-on-submit pattern (introduced for ClientFormModal / ClientBulkAddModal) to five more forms: - ClientBulkAdjustModal: ClientBulkAdjustFormSchema enforces 'at least one of addDays / addGB is non-zero' via .refine(), replacing the ad-hoc days+gb check. - BalancerFormModal: BalancerFormSchema covers tag and selector required-ness; the duplicate-tag check stays inline since it needs the otherTags prop. Per-field validateStatus now reads from the parsed issues map. - RuleFormModal: RuleFormSchema captures the form shape (no required fields - every property is optional by design). safeParse short- circuits if anything is structurally wrong. - CustomGeoFormModal: CustomGeoFormSchema folds the regex alias rule and the http(s) URL validation (including URL parse) into the schema, replacing a 20-line validate() function. - TwoFactorModal: TotpCodeSchema (z.string().regex(/^\d{6}$/)) drives both the disabled-state of the OK button and the safeParse gate before the TOTP comparison. Schemas live alongside the matching API schemas: - ClientBulkAdjustFormSchema in schemas/client.ts - BalancerFormSchema / RuleFormSchema / CustomGeoFormSchema in schemas/xray.ts - TotpCodeSchema in schemas/login.ts (next to LoginFormSchema) No UX change for valid inputs. --- .../pages/clients/ClientBulkAdjustModal.tsx | 13 ++++-- .../src/pages/index/CustomGeoFormModal.tsx | 32 +++---------- .../src/pages/settings/TwoFactorModal.tsx | 12 +++-- frontend/src/pages/xray/BalancerFormModal.tsx | 41 ++++++++++------- frontend/src/pages/xray/RuleFormModal.tsx | 46 ++++++++----------- frontend/src/schemas/client.ts | 10 ++++ frontend/src/schemas/login.ts | 4 ++ frontend/src/schemas/xray.ts | 46 +++++++++++++++++++ 8 files changed, 128 insertions(+), 76 deletions(-) diff --git a/frontend/src/pages/clients/ClientBulkAdjustModal.tsx b/frontend/src/pages/clients/ClientBulkAdjustModal.tsx index b13dcdea..e9f770c5 100644 --- a/frontend/src/pages/clients/ClientBulkAdjustModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAdjustModal.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Form, InputNumber, Modal, message } from 'antd'; +import { ClientBulkAdjustFormSchema } from '@/schemas/client'; + const GB = 1024 * 1024 * 1024; interface ClientBulkAdjustModalProps { @@ -26,12 +28,15 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub }, [open]); async function handleOk() { - const days = Math.trunc(Number(addDays) || 0); - const gb = Number(addGB) || 0; - if (days === 0 && gb === 0) { - messageApi.warning(t('pages.clients.bulkAdjustNothing')); + const validated = ClientBulkAdjustFormSchema.safeParse({ + addDays: Math.trunc(Number(addDays) || 0), + addGB: Number(addGB) || 0, + }); + if (!validated.success) { + messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong')); return; } + const { addDays: days, addGB: gb } = validated.data; setSubmitting(true); try { const bytes = Math.trunc(gb * GB); diff --git a/frontend/src/pages/index/CustomGeoFormModal.tsx b/frontend/src/pages/index/CustomGeoFormModal.tsx index 9f4b087e..f8f8cb2a 100644 --- a/frontend/src/pages/index/CustomGeoFormModal.tsx +++ b/frontend/src/pages/index/CustomGeoFormModal.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Form, Input, message, Modal, Select } from 'antd'; import { HttpUtil } from '@/utils'; +import { CustomGeoFormSchema } from '@/schemas/xray'; export interface CustomGeoRecord { id: number; @@ -46,37 +47,18 @@ export default function CustomGeoFormModal({ } }, [open, record]); - function validate(): boolean { - if (!/^[a-z0-9_-]+$/.test(alias || '')) { - messageApi.error(t('pages.index.customGeoValidationAlias')); - return false; - } - const u = (url || '').trim(); - if (!/^https?:\/\//i.test(u)) { - messageApi.error(t('pages.index.customGeoValidationUrl')); - return false; - } - try { - const parsed = new URL(u); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - messageApi.error(t('pages.index.customGeoValidationUrl')); - return false; - } - } catch { - messageApi.error(t('pages.index.customGeoValidationUrl')); - return false; - } - return true; - } - async function submit() { - if (!validate()) return; + const validated = CustomGeoFormSchema.safeParse({ type, alias, url }); + if (!validated.success) { + messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong')); + return; + } setSaving(true); try { const apiUrl = editing ? `/panel/api/custom-geo/update/${record!.id}` : '/panel/api/custom-geo/add'; - const msg = await HttpUtil.post(apiUrl, { type, alias, url }); + const msg = await HttpUtil.post(apiUrl, validated.data); if (msg?.success) { onSaved(); onClose(); diff --git a/frontend/src/pages/settings/TwoFactorModal.tsx b/frontend/src/pages/settings/TwoFactorModal.tsx index b686926c..1aacc20a 100644 --- a/frontend/src/pages/settings/TwoFactorModal.tsx +++ b/frontend/src/pages/settings/TwoFactorModal.tsx @@ -4,6 +4,7 @@ import { Button, Divider, Input, Modal, QRCode, message } from 'antd'; import * as OTPAuth from 'otpauth'; import { ClipboardManager } from '@/utils'; +import { TotpCodeSchema } from '@/schemas/login'; import './TwoFactorModal.css'; type Type = 'set' | 'confirm'; @@ -61,12 +62,17 @@ export default function TwoFactorModal({ } function onOk() { + const codeOk = TotpCodeSchema.safeParse(enteredCode); + if (!codeOk.success) { + messageApi.error(t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError')); + return; + } if (type === 'confirm' && !token) { - close(true, enteredCode); + close(true, codeOk.data); return; } if (!totpRef.current) return; - if (totpRef.current.generate() === enteredCode) { + if (totpRef.current.generate() === codeOk.data) { close(true); } else { messageApi.error(t('pages.settings.security.twoFactorModalError')); @@ -92,7 +98,7 @@ export default function TwoFactorModal({ onCancel={onCancel} footer={[ , - , ]} diff --git a/frontend/src/pages/xray/BalancerFormModal.tsx b/frontend/src/pages/xray/BalancerFormModal.tsx index 8008ab40..de3d8d8d 100644 --- a/frontend/src/pages/xray/BalancerFormModal.tsx +++ b/frontend/src/pages/xray/BalancerFormModal.tsx @@ -2,12 +2,9 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Form, Input, Modal, Select } from 'antd'; -export interface BalancerFormValue { - tag: string; - strategy: string; - selector: string[]; - fallbackTag: string; -} +import { BalancerFormSchema, type BalancerFormValues } from '@/schemas/xray'; + +export type BalancerFormValue = BalancerFormValues; interface BalancerFormModalProps { open: boolean; @@ -56,28 +53,40 @@ export default function BalancerFormModal({ } }, [open, balancer]); - const tagEmpty = !tag.trim(); - const duplicateTag = !!tag && otherTags.includes(tag.trim()); - const emptySelector = selector.length === 0; - const isValid = !tagEmpty && !duplicateTag && !emptySelector; + const parsed = useMemo( + () => BalancerFormSchema.safeParse({ tag, strategy, selector, fallbackTag }), + [tag, strategy, selector, fallbackTag], + ); + const duplicateTag = !!tag.trim() && otherTags.includes(tag.trim()); + const issuesByField = useMemo(() => { + const map: Record = {}; + if (!parsed.success) { + for (const issue of parsed.error.issues) { + const key = String(issue.path[0] ?? ''); + if (!map[key]) map[key] = issue.message; + } + } + return map; + }, [parsed]); + const isValid = parsed.success && !duplicateTag; - const tagValidateStatus: 'error' | 'warning' | 'success' = tagEmpty + const tagValidateStatus: 'error' | 'warning' | 'success' = issuesByField.tag ? 'error' : duplicateTag ? 'warning' : 'success'; - const tagHelp = tagEmpty + const tagHelp = issuesByField.tag ? 'Tag is required' : duplicateTag ? 'Tag already used by another balancer' : ''; - const selectorValidateStatus: 'error' | 'success' = emptySelector ? 'error' : 'success'; - const selectorHelp = emptySelector ? 'Pick at least one outbound' : ''; + const selectorValidateStatus: 'error' | 'success' = issuesByField.selector ? 'error' : 'success'; + const selectorHelp = issuesByField.selector ? 'Pick at least one outbound' : ''; function submit() { - if (!isValid) return; - onConfirm({ tag, strategy, selector, fallbackTag }); + if (!parsed.success || duplicateTag) return; + onConfirm(parsed.data); } const title = isEdit diff --git a/frontend/src/pages/xray/RuleFormModal.tsx b/frontend/src/pages/xray/RuleFormModal.tsx index 2c14c638..63595e2b 100644 --- a/frontend/src/pages/xray/RuleFormModal.tsx +++ b/frontend/src/pages/xray/RuleFormModal.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd'; import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import InputAddon from '@/components/InputAddon'; +import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray'; export interface RoutingRule { type?: string; @@ -32,21 +33,7 @@ interface RuleFormModalProps { onConfirm: (rule: Record) => void; } -interface FormState { - domain: string; - ip: string; - port: string; - sourcePort: string; - vlessRoute: string; - network: string; - sourceIP: string; - user: string; - inboundTag: string[]; - protocol: string[]; - attrs: [string, string][]; - outboundTag: string; - balancerTag: string; -} +type FormState = RuleFormValues; const initialForm = (): FormState => ({ domain: '', @@ -112,21 +99,24 @@ export default function RuleFormModal({ setForm((prev) => ({ ...prev, [key]: value })); function submit() { + const validated = RuleFormSchema.safeParse(form); + if (!validated.success) return; + const v = validated.data; const built: Record = { type: 'field', - domain: csv(form.domain), - ip: csv(form.ip), - port: form.port, - sourcePort: form.sourcePort, - vlessRoute: form.vlessRoute, - network: form.network, - sourceIP: csv(form.sourceIP), - user: csv(form.user), - inboundTag: form.inboundTag, - protocol: form.protocol, - attrs: Object.fromEntries(form.attrs.filter(([k]) => k)), - outboundTag: form.outboundTag === '' ? undefined : form.outboundTag, - balancerTag: form.balancerTag === '' ? undefined : form.balancerTag, + domain: csv(v.domain), + ip: csv(v.ip), + port: v.port, + sourcePort: v.sourcePort, + vlessRoute: v.vlessRoute, + network: v.network, + sourceIP: csv(v.sourceIP), + user: csv(v.user), + inboundTag: v.inboundTag, + protocol: v.protocol, + attrs: Object.fromEntries(v.attrs.filter(([k]) => k)), + outboundTag: v.outboundTag === '' ? undefined : v.outboundTag, + balancerTag: v.balancerTag === '' ? undefined : v.balancerTag, }; const out: Record = {}; for (const [k, v] of Object.entries(built)) { diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 02059c61..87c4e82a 100644 --- a/frontend/src/schemas/client.ts +++ b/frontend/src/schemas/client.ts @@ -105,6 +105,15 @@ export const ClientCreateFormSchema = ClientFormSchema.extend({ inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'), }); +export const ClientBulkAdjustFormSchema = z + .object({ + addDays: z.number().int(), + addGB: z.number(), + }) + .refine((v) => v.addDays !== 0 || v.addGB !== 0, { + message: 'pages.clients.bulkAdjustNothing', + }); + export const ClientBulkAddFormSchema = z.object({ emailMethod: z.number().int().min(0).max(4), firstNum: z.number().int().min(1), @@ -129,4 +138,5 @@ export type ClientPageResponse = z.infer; export type ClientHydrate = z.infer; export type BulkAdjustResult = z.infer; export type ClientBulkAddFormValues = z.infer; +export type ClientBulkAdjustFormValues = z.infer; export type ClientFormValues = z.infer; diff --git a/frontend/src/schemas/login.ts b/frontend/src/schemas/login.ts index bba389af..20b1c730 100644 --- a/frontend/src/schemas/login.ts +++ b/frontend/src/schemas/login.ts @@ -8,4 +8,8 @@ export const LoginFormSchema = z.object({ export const TwoFactorCodeSchema = z.string().min(1, 'twoFactorCode'); +export const TotpCodeSchema = z + .string() + .regex(/^\d{6}$/, 'pages.settings.security.twoFactorModalError'); + export type LoginFormValues = z.infer; diff --git a/frontend/src/schemas/xray.ts b/frontend/src/schemas/xray.ts index 97e41da7..66d66407 100644 --- a/frontend/src/schemas/xray.ts +++ b/frontend/src/schemas/xray.ts @@ -71,6 +71,52 @@ export const OutboundTestResultSchema = z.object({ .optional(), }).loose(); +export const CustomGeoFormSchema = z.object({ + type: z.enum(['geosite', 'geoip']), + alias: z.string().regex(/^[a-z0-9_-]+$/, 'pages.index.customGeoValidationAlias'), + url: z + .string() + .trim() + .refine( + (u) => { + if (!/^https?:\/\//i.test(u)) return false; + try { + const parsed = new URL(u); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } + }, + { message: 'pages.index.customGeoValidationUrl' }, + ), +}); + +export const RuleFormSchema = z.object({ + domain: z.string(), + ip: z.string(), + port: z.string(), + sourcePort: z.string(), + vlessRoute: z.string(), + network: z.string(), + sourceIP: z.string(), + user: z.string(), + inboundTag: z.array(z.string()), + protocol: z.array(z.string()), + attrs: z.array(z.tuple([z.string(), z.string()])), + outboundTag: z.string(), + balancerTag: z.string(), +}); + +export const BalancerFormSchema = z.object({ + tag: z.string().trim().min(1, 'pages.xray.balancerTagRequired'), + strategy: z.string(), + selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'), + fallbackTag: z.string(), +}); + +export type BalancerFormValues = z.infer; +export type RuleFormValues = z.infer; +export type CustomGeoFormValues = z.infer; export type XraySettingsValue = z.infer; export type XrayConfigPayload = z.infer; export type OutboundTrafficRow = z.infer;