mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +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:
parent
2d55b3b663
commit
a3012daa8f
8 changed files with 128 additions and 76 deletions
|
|
@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Form, InputNumber, Modal, message } from 'antd';
|
import { Alert, Form, InputNumber, Modal, message } from 'antd';
|
||||||
|
|
||||||
|
import { ClientBulkAdjustFormSchema } from '@/schemas/client';
|
||||||
|
|
||||||
const GB = 1024 * 1024 * 1024;
|
const GB = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
interface ClientBulkAdjustModalProps {
|
interface ClientBulkAdjustModalProps {
|
||||||
|
|
@ -26,12 +28,15 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
async function handleOk() {
|
async function handleOk() {
|
||||||
const days = Math.trunc(Number(addDays) || 0);
|
const validated = ClientBulkAdjustFormSchema.safeParse({
|
||||||
const gb = Number(addGB) || 0;
|
addDays: Math.trunc(Number(addDays) || 0),
|
||||||
if (days === 0 && gb === 0) {
|
addGB: Number(addGB) || 0,
|
||||||
messageApi.warning(t('pages.clients.bulkAdjustNothing'));
|
});
|
||||||
|
if (!validated.success) {
|
||||||
|
messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { addDays: days, addGB: gb } = validated.data;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const bytes = Math.trunc(gb * GB);
|
const bytes = Math.trunc(gb * GB);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { Form, Input, message, Modal, Select } from 'antd';
|
import { Form, Input, message, Modal, Select } from 'antd';
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { CustomGeoFormSchema } from '@/schemas/xray';
|
||||||
|
|
||||||
export interface CustomGeoRecord {
|
export interface CustomGeoRecord {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -46,37 +47,18 @@ export default function CustomGeoFormModal({
|
||||||
}
|
}
|
||||||
}, [open, record]);
|
}, [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() {
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const apiUrl = editing
|
const apiUrl = editing
|
||||||
? `/panel/api/custom-geo/update/${record!.id}`
|
? `/panel/api/custom-geo/update/${record!.id}`
|
||||||
: '/panel/api/custom-geo/add';
|
: '/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) {
|
if (msg?.success) {
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
|
||||||
import * as OTPAuth from 'otpauth';
|
import * as OTPAuth from 'otpauth';
|
||||||
|
|
||||||
import { ClipboardManager } from '@/utils';
|
import { ClipboardManager } from '@/utils';
|
||||||
|
import { TotpCodeSchema } from '@/schemas/login';
|
||||||
import './TwoFactorModal.css';
|
import './TwoFactorModal.css';
|
||||||
|
|
||||||
type Type = 'set' | 'confirm';
|
type Type = 'set' | 'confirm';
|
||||||
|
|
@ -61,12 +62,17 @@ export default function TwoFactorModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOk() {
|
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) {
|
if (type === 'confirm' && !token) {
|
||||||
close(true, enteredCode);
|
close(true, codeOk.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!totpRef.current) return;
|
if (!totpRef.current) return;
|
||||||
if (totpRef.current.generate() === enteredCode) {
|
if (totpRef.current.generate() === codeOk.data) {
|
||||||
close(true);
|
close(true);
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(t('pages.settings.security.twoFactorModalError'));
|
messageApi.error(t('pages.settings.security.twoFactorModalError'));
|
||||||
|
|
@ -92,7 +98,7 @@ export default function TwoFactorModal({
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="cancel" onClick={onCancel}>{t('cancel')}</Button>,
|
<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')}
|
{t('confirm')}
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,9 @@ import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Form, Input, Modal, Select } from 'antd';
|
import { Form, Input, Modal, Select } from 'antd';
|
||||||
|
|
||||||
export interface BalancerFormValue {
|
import { BalancerFormSchema, type BalancerFormValues } from '@/schemas/xray';
|
||||||
tag: string;
|
|
||||||
strategy: string;
|
export type BalancerFormValue = BalancerFormValues;
|
||||||
selector: string[];
|
|
||||||
fallbackTag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BalancerFormModalProps {
|
interface BalancerFormModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -56,28 +53,40 @@ export default function BalancerFormModal({
|
||||||
}
|
}
|
||||||
}, [open, balancer]);
|
}, [open, balancer]);
|
||||||
|
|
||||||
const tagEmpty = !tag.trim();
|
const parsed = useMemo(
|
||||||
const duplicateTag = !!tag && otherTags.includes(tag.trim());
|
() => BalancerFormSchema.safeParse({ tag, strategy, selector, fallbackTag }),
|
||||||
const emptySelector = selector.length === 0;
|
[tag, strategy, selector, fallbackTag],
|
||||||
const isValid = !tagEmpty && !duplicateTag && !emptySelector;
|
);
|
||||||
|
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'
|
? 'error'
|
||||||
: duplicateTag
|
: duplicateTag
|
||||||
? 'warning'
|
? 'warning'
|
||||||
: 'success';
|
: 'success';
|
||||||
const tagHelp = tagEmpty
|
const tagHelp = issuesByField.tag
|
||||||
? 'Tag is required'
|
? 'Tag is required'
|
||||||
: duplicateTag
|
: duplicateTag
|
||||||
? 'Tag already used by another balancer'
|
? 'Tag already used by another balancer'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const selectorValidateStatus: 'error' | 'success' = emptySelector ? 'error' : 'success';
|
const selectorValidateStatus: 'error' | 'success' = issuesByField.selector ? 'error' : 'success';
|
||||||
const selectorHelp = emptySelector ? 'Pick at least one outbound' : '';
|
const selectorHelp = issuesByField.selector ? 'Pick at least one outbound' : '';
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
if (!isValid) return;
|
if (!parsed.success || duplicateTag) return;
|
||||||
onConfirm({ tag, strategy, selector, fallbackTag });
|
onConfirm(parsed.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = isEdit
|
const title = isEdit
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd';
|
import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd';
|
||||||
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
import InputAddon from '@/components/InputAddon';
|
import InputAddon from '@/components/InputAddon';
|
||||||
|
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
|
||||||
|
|
||||||
export interface RoutingRule {
|
export interface RoutingRule {
|
||||||
type?: string;
|
type?: string;
|
||||||
|
|
@ -32,21 +33,7 @@ interface RuleFormModalProps {
|
||||||
onConfirm: (rule: Record<string, unknown>) => void;
|
onConfirm: (rule: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormState {
|
type FormState = RuleFormValues;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialForm = (): FormState => ({
|
const initialForm = (): FormState => ({
|
||||||
domain: '',
|
domain: '',
|
||||||
|
|
@ -112,21 +99,24 @@ export default function RuleFormModal({
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
|
const validated = RuleFormSchema.safeParse(form);
|
||||||
|
if (!validated.success) return;
|
||||||
|
const v = validated.data;
|
||||||
const built: Record<string, unknown> = {
|
const built: Record<string, unknown> = {
|
||||||
type: 'field',
|
type: 'field',
|
||||||
domain: csv(form.domain),
|
domain: csv(v.domain),
|
||||||
ip: csv(form.ip),
|
ip: csv(v.ip),
|
||||||
port: form.port,
|
port: v.port,
|
||||||
sourcePort: form.sourcePort,
|
sourcePort: v.sourcePort,
|
||||||
vlessRoute: form.vlessRoute,
|
vlessRoute: v.vlessRoute,
|
||||||
network: form.network,
|
network: v.network,
|
||||||
sourceIP: csv(form.sourceIP),
|
sourceIP: csv(v.sourceIP),
|
||||||
user: csv(form.user),
|
user: csv(v.user),
|
||||||
inboundTag: form.inboundTag,
|
inboundTag: v.inboundTag,
|
||||||
protocol: form.protocol,
|
protocol: v.protocol,
|
||||||
attrs: Object.fromEntries(form.attrs.filter(([k]) => k)),
|
attrs: Object.fromEntries(v.attrs.filter(([k]) => k)),
|
||||||
outboundTag: form.outboundTag === '' ? undefined : form.outboundTag,
|
outboundTag: v.outboundTag === '' ? undefined : v.outboundTag,
|
||||||
balancerTag: form.balancerTag === '' ? undefined : form.balancerTag,
|
balancerTag: v.balancerTag === '' ? undefined : v.balancerTag,
|
||||||
};
|
};
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(built)) {
|
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'),
|
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({
|
export const ClientBulkAddFormSchema = z.object({
|
||||||
emailMethod: z.number().int().min(0).max(4),
|
emailMethod: z.number().int().min(0).max(4),
|
||||||
firstNum: z.number().int().min(1),
|
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 ClientHydrate = z.infer<typeof ClientHydrateSchema>;
|
||||||
export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
|
export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
|
||||||
export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>;
|
export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>;
|
||||||
|
export type ClientBulkAdjustFormValues = z.infer<typeof ClientBulkAdjustFormSchema>;
|
||||||
export type ClientFormValues = z.infer<typeof ClientFormSchema>;
|
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 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>;
|
export type LoginFormValues = z.infer<typeof LoginFormSchema>;
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,52 @@ export const OutboundTestResultSchema = z.object({
|
||||||
.optional(),
|
.optional(),
|
||||||
}).loose();
|
}).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 XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
|
||||||
export type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
export type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
||||||
export type OutboundTrafficRow = z.infer<typeof OutboundTrafficRowSchema>;
|
export type OutboundTrafficRow = z.infer<typeof OutboundTrafficRowSchema>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue