mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-03 02:49:36 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
]}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user