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.
This commit is contained in:
MHSanaei
2026-05-25 17:45:02 +02:00
parent 2d55b3b663
commit a3012daa8f
8 changed files with 128 additions and 76 deletions

View File

@@ -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);

View File

@@ -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();

View File

@@ -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={[
<Button key="cancel" onClick={onCancel}>{t('cancel')}</Button>,
<Button key="ok" type="primary" disabled={enteredCode.length < 6} onClick={onOk}>
<Button key="ok" type="primary" disabled={!TotpCodeSchema.safeParse(enteredCode).success} onClick={onOk}>
{t('confirm')}
</Button>,
]}

View File

@@ -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<string, string> = {};
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

View File

@@ -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<string, unknown>) => 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<string, unknown> = {
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<string, unknown> = {};
for (const [k, v] of Object.entries(built)) {

View File

@@ -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<typeof ClientPageResponseSchema>;
export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>;
export type ClientBulkAdjustFormValues = z.infer<typeof ClientBulkAdjustFormSchema>;
export type ClientFormValues = z.infer<typeof ClientFormSchema>;

View File

@@ -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<typeof LoginFormSchema>;

View File

@@ -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<typeof BalancerFormSchema>;
export type RuleFormValues = z.infer<typeof RuleFormSchema>;
export type CustomGeoFormValues = z.infer<typeof CustomGeoFormSchema>;
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
export type OutboundTrafficRow = z.infer<typeof OutboundTrafficRowSchema>;