Jelajahi Sumber

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.
MHSanaei 14 jam lalu
induk
melakukan
a3012daa8f

+ 9 - 4
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);

+ 7 - 25
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();

+ 9 - 3
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={[
         <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>,
       ]}

+ 25 - 16
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<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

+ 18 - 28
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<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)) {

+ 10 - 0
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<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>;

+ 4 - 0
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<typeof LoginFormSchema>;

+ 46 - 0
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<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>;