From 76dbbfc1f8d7831e3403cf0858ec9d8697603a46 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 31 May 2026 22:41:58 +0200 Subject: [PATCH] feat(inbounds): clearer client validation errors on save When an inbound save fails Zod validation, the toast previously showed a raw path like `settings.clients.494.tgId: Invalid input`, which gave no hint which of hundreds of clients was at fault. Resolve the client array index back to the client email, name the field, and append a "(+N more)" count when several fields fail. console.error now logs a readable list of every issue instead of dumping the whole form. Adds the invalidClientField/invalidField/moreIssues toast strings across all 13 translations. --- .../pages/inbounds/form/InboundFormModal.tsx | 19 ++---- .../inbounds/form/formatValidationError.ts | 43 ++++++++++++ .../src/test/format-validation-error.test.ts | 65 +++++++++++++++++++ web/translation/ar-EG.json | 5 +- web/translation/en-US.json | 5 +- web/translation/es-ES.json | 5 +- web/translation/fa-IR.json | 5 +- web/translation/id-ID.json | 5 +- web/translation/ja-JP.json | 5 +- web/translation/pt-BR.json | 5 +- web/translation/ru-RU.json | 5 +- web/translation/tr-TR.json | 5 +- web/translation/uk-UA.json | 5 +- web/translation/vi-VN.json | 5 +- web/translation/zh-CN.json | 5 +- web/translation/zh-TW.json | 5 +- 16 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 frontend/src/pages/inbounds/form/formatValidationError.ts create mode 100644 frontend/src/test/format-validation-error.test.ts diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 5161d5f2..eb1551c7 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -48,6 +48,7 @@ import { FinalMaskForm } from '@/lib/xray/forms/transport'; import './InboundFormModal.css'; import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors'; +import { formatInboundIssue, formatInboundValidation } from './formatValidationError'; import { HttpFields, HysteriaFields, @@ -360,18 +361,12 @@ export default function InboundFormModal({ const values = form.getFieldsValue(true) as InboundFormValues; const parsed = InboundFormSchema.safeParse(values); if (!parsed.success) { - const issue = parsed.error.issues[0]; - const path = Array.isArray(issue?.path) && issue.path.length > 0 - ? issue.path.join('.') - : ''; - const baseMsg = issue?.message ?? 'somethingWentWrong'; - const display = path ? `${path}: ${baseMsg}` : baseMsg; - messageApi.error(t(baseMsg, { defaultValue: display })); - console.error('[InboundFormModal] schema validation failed', { - path: issue?.path, - message: issue?.message, - values, - }); + const issues = parsed.error.issues; + messageApi.error(formatInboundValidation(issues, values, t)); + console.error( + '[InboundFormModal] schema validation failed:', + issues.map((issue) => formatInboundIssue(issue, values, t)), + ); return; } setSaving(true); diff --git a/frontend/src/pages/inbounds/form/formatValidationError.ts b/frontend/src/pages/inbounds/form/formatValidationError.ts new file mode 100644 index 00000000..a56d9577 --- /dev/null +++ b/frontend/src/pages/inbounds/form/formatValidationError.ts @@ -0,0 +1,43 @@ +import type { TFunction } from 'i18next'; + +type IssueLike = { path: PropertyKey[]; message: string }; + +interface ClientLike { + email?: unknown; +} + +/** + * Turns one Zod issue from the inbound-form schema into a human-readable line. + * The schema validates the whole form at once, so a bad client field surfaces + * as `settings.clients..` — useless on its own when an inbound + * holds hundreds of clients. We resolve that index back to the client's email + * so the operator can find the offending entry. The reason is translated when + * it is a custom message key; Zod defaults like "Invalid input" pass through. + */ +export function formatInboundIssue(issue: IssueLike, values: unknown, t: TFunction): string { + const path = Array.isArray(issue?.path) ? issue.path : []; + const reason = t(issue?.message, { defaultValue: issue?.message }); + + if (path[0] === 'settings' && path[1] === 'clients' && typeof path[2] === 'number') { + const index = path[2]; + const clients = (values as { settings?: { clients?: ClientLike[] } })?.settings?.clients; + const client = Array.isArray(clients) ? clients[index] : undefined; + const email = typeof client?.email === 'string' && client.email !== '' ? client.email : ''; + const who = email ? `"${email}"` : `#${index}`; + const field = path.slice(3).map(String).join('.') || t('clients'); + return t('pages.inbounds.toasts.invalidClientField', { client: who, field, reason }); + } + + const field = path.map(String).join('.') || 'value'; + return t('pages.inbounds.toasts.invalidField', { field, reason }); +} + +/** + * Builds the single-line toast for a failed inbound save: the first issue, + * fully described, plus a "(+N more)" tail when several fields failed. + */ +export function formatInboundValidation(issues: IssueLike[], values: unknown, t: TFunction): string { + const first = formatInboundIssue(issues[0], values, t); + if (issues.length <= 1) return first; + return t('pages.inbounds.toasts.moreIssues', { message: first, count: issues.length - 1 }); +} diff --git a/frontend/src/test/format-validation-error.test.ts b/frontend/src/test/format-validation-error.test.ts new file mode 100644 index 00000000..1144b800 --- /dev/null +++ b/frontend/src/test/format-validation-error.test.ts @@ -0,0 +1,65 @@ +/// +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import type { TFunction } from 'i18next'; + +import { formatInboundIssue, formatInboundValidation } from '@/pages/inbounds/form/formatValidationError'; + +const templates: Record = { + 'pages.inbounds.toasts.invalidClientField': 'Client {client}: {field} — {reason}', + 'pages.inbounds.toasts.invalidField': '{field} — {reason}', + 'pages.inbounds.toasts.moreIssues': '{message} (+{count} more)', + clients: 'clients', +}; + +const t = ((key: string, opts?: Record) => { + let out = templates[key] ?? (opts?.defaultValue as string | undefined) ?? key; + if (opts) { + for (const [k, v] of Object.entries(opts)) { + out = out.split(`{${k}}`).join(String(v)); + } + } + return out; +}) as unknown as TFunction; + +describe('formatInboundValidation', () => { + it('resolves a real client array index back to the client email', () => { + const schema = z.object({ + settings: z.object({ + clients: z.array(z.object({ email: z.string(), tgId: z.number() })), + }), + }); + const values = { + settings: { + clients: [ + { email: 'first@x.com', tgId: 1 }, + { email: 'broken@x.com', tgId: 'oops' }, + ], + }, + }; + const parsed = schema.safeParse(values); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(formatInboundIssue(parsed.error.issues[0], values, t)).toContain('Client "broken@x.com": tgId — '); + }); + + it('falls back to the index when the client has no email', () => { + const issue = { path: ['settings', 'clients', 7, 'tgId'], message: 'Invalid input' }; + const values = { settings: { clients: [] } }; + expect(formatInboundIssue(issue, values, t)).toBe('Client #7: tgId — Invalid input'); + }); + + it('formats non-client paths plainly', () => { + const issue = { path: ['port'], message: 'Invalid input' }; + expect(formatInboundIssue(issue, {}, t)).toBe('port — Invalid input'); + }); + + it('appends a count when several fields fail', () => { + const issues = [ + { path: ['settings', 'clients', 0, 'tgId'], message: 'Invalid input' }, + { path: ['port'], message: 'Invalid input' }, + ]; + const values = { settings: { clients: [{ email: 'a@x.com' }] } }; + expect(formatInboundValidation(issues, values, t)).toBe('Client "a@x.com": tgId — Invalid input (+1 more)'); + }); +}); diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index b78b12ef..f6096c00 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -442,7 +442,10 @@ "trafficGetError": "خطأ في الحصول على حركات المرور", "getNewX25519CertError": "حدث خطأ أثناء الحصول على شهادة X25519.", "getNewmldsa65Error": "حدث خطاء في الحصول على mldsa65.", - "getNewVlessEncError": "حدث خطأ أثناء الحصول على VlessEnc." + "getNewVlessEncError": "حدث خطأ أثناء الحصول على VlessEnc.", + "invalidClientField": "العميل {client}: الحقل {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (+{count} أخرى)" }, "form": { "moveUp": "أعلى", diff --git a/web/translation/en-US.json b/web/translation/en-US.json index c8f8b016..e8758192 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -442,7 +442,10 @@ "trafficGetError": "Error getting traffic.", "getNewX25519CertError": "Error while obtaining the X25519 certificate.", "getNewmldsa65Error": "Error while obtaining mldsa65.", - "getNewVlessEncError": "Error while obtaining VlessEnc." + "getNewVlessEncError": "Error while obtaining VlessEnc.", + "invalidClientField": "Client {client}: {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (+{count} more)" }, "form": { "moveUp": "Move up", diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json index 7eac3ebb..9f8c0c0c 100644 --- a/web/translation/es-ES.json +++ b/web/translation/es-ES.json @@ -442,7 +442,10 @@ "trafficGetError": "Error al obtener los tráficos", "getNewX25519CertError": "Error al obtener el certificado X25519.", "getNewmldsa65Error": "Error al obtener el certificado mldsa65.", - "getNewVlessEncError": "Error al obtener el certificado VlessEnc." + "getNewVlessEncError": "Error al obtener el certificado VlessEnc.", + "invalidClientField": "Cliente {client}: campo {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (+{count} más)" }, "form": { "moveUp": "Subir", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index 5810a862..4f6c7283 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -442,7 +442,10 @@ "trafficGetError": "خطا در دریافت ترافیک‌ها", "getNewX25519CertError": "خطا در دریافت گواهی X25519.", "getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.", - "getNewVlessEncError": "خطا در دریافت گواهی VlessEnc." + "getNewVlessEncError": "خطا در دریافت گواهی VlessEnc.", + "invalidClientField": "کلاینت {client}: فیلد {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (+{count} مورد دیگر)" }, "form": { "moveUp": "بالا", diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json index c9077658..1e04cede 100644 --- a/web/translation/id-ID.json +++ b/web/translation/id-ID.json @@ -442,7 +442,10 @@ "trafficGetError": "Gagal mendapatkan data lalu lintas", "getNewX25519CertError": "Terjadi kesalahan saat mendapatkan sertifikat X25519.", "getNewmldsa65Error": "Terjadi kesalahan saat mendapatkan sertifikat mldsa65.", - "getNewVlessEncError": "Terjadi kesalahan saat mendapatkan sertifikat VlessEnc." + "getNewVlessEncError": "Terjadi kesalahan saat mendapatkan sertifikat VlessEnc.", + "invalidClientField": "Klien {client}: kolom {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (+{count} lainnya)" }, "form": { "moveUp": "Naik", diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json index f4831854..8fee6d12 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -442,7 +442,10 @@ "trafficGetError": "トラフィックの取得中にエラーが発生しました", "getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。", "getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。", - "getNewVlessEncError": "VlessEnc証明書の取得中にエラーが発生しました。" + "getNewVlessEncError": "VlessEnc証明書の取得中にエラーが発生しました。", + "invalidClientField": "クライアント {client}: フィールド {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (他 {count} 件)" }, "form": { "moveUp": "上へ", diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json index 4cfe7f7a..3cf77b08 100644 --- a/web/translation/pt-BR.json +++ b/web/translation/pt-BR.json @@ -442,7 +442,10 @@ "trafficGetError": "Erro ao obter tráfegos", "getNewX25519CertError": "Erro ao obter o certificado X25519.", "getNewmldsa65Error": "Erro ao obter o certificado mldsa65.", - "getNewVlessEncError": "Erro ao obter o certificado VlessEnc." + "getNewVlessEncError": "Erro ao obter o certificado VlessEnc.", + "invalidClientField": "Cliente {client}: campo {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (+{count} mais)" }, "form": { "moveUp": "Mover para cima", diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json index b7f198b2..68102712 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -442,7 +442,10 @@ "trafficGetError": "Ошибка получения данных о трафике", "getNewX25519CertError": "Ошибка при получении сертификата X25519.", "getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.", - "getNewVlessEncError": "Ошибка при получении сертификата VlessEnc." + "getNewVlessEncError": "Ошибка при получении сертификата VlessEnc.", + "invalidClientField": "Клиент {client}: поле {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (+{count} ещё)" }, "form": { "moveUp": "Вверх", diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json index aea4696c..cc097f60 100644 --- a/web/translation/tr-TR.json +++ b/web/translation/tr-TR.json @@ -442,7 +442,10 @@ "trafficGetError": "Trafik bilgisi alınırken hata oluştu", "getNewX25519CertError": "X25519 sertifikası alınırken hata oluştu.", "getNewmldsa65Error": "mldsa65 sertifikası alınırken hata oluştu.", - "getNewVlessEncError": "VlessEnc sertifikası alınırken hata oluştu." + "getNewVlessEncError": "VlessEnc sertifikası alınırken hata oluştu.", + "invalidClientField": "Müşteri {client}: alan {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (+{count} tane daha)" }, "form": { "moveUp": "Yukarı", diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json index b3a11c48..f1f7c6a5 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -442,7 +442,10 @@ "trafficGetError": "Помилка отримання даних про трафік", "getNewX25519CertError": "Помилка при отриманні сертифіката X25519.", "getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.", - "getNewVlessEncError": "Помилка при отриманні сертифіката VlessEnc." + "getNewVlessEncError": "Помилка при отриманні сертифіката VlessEnc.", + "invalidClientField": "Клієнт {client}: поле {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (+{count} ще)" }, "form": { "moveUp": "Вгору", diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json index 5aba20d6..6d82801c 100644 --- a/web/translation/vi-VN.json +++ b/web/translation/vi-VN.json @@ -442,7 +442,10 @@ "trafficGetError": "Lỗi khi lấy thông tin lưu lượng", "getNewX25519CertError": "Lỗi khi lấy chứng chỉ X25519.", "getNewmldsa65Error": "Lỗi khi lấy chứng chỉ mldsa65.", - "getNewVlessEncError": "Lỗi khi lấy chứng chỉ VlessEnc." + "getNewVlessEncError": "Lỗi khi lấy chứng chỉ VlessEnc.", + "invalidClientField": "Khách hàng {client}: trường {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (+{count} lỗi khác)" }, "form": { "moveUp": "Lên", diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json index 06414ab6..427b0ec9 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -442,7 +442,10 @@ "trafficGetError": "获取流量数据时出错", "getNewX25519CertError": "获取X25519证书时出错。", "getNewmldsa65Error": "获取mldsa65证书时出错。", - "getNewVlessEncError": "获取VlessEnc证书时出错。" + "getNewVlessEncError": "获取VlessEnc证书时出错。", + "invalidClientField": "客户端 {client}:字段 {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (另有 {count} 项)" }, "form": { "moveUp": "上移", diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json index 62d5034f..62350109 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -442,7 +442,10 @@ "trafficGetError": "取得流量資料時發生錯誤", "getNewX25519CertError": "取得X25519憑證時發生錯誤。", "getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。", - "getNewVlessEncError": "取得VlessEnc憑證時發生錯誤。" + "getNewVlessEncError": "取得VlessEnc憑證時發生錯誤。", + "invalidClientField": "用戶端 {client}:欄位 {field} — {reason}", + "invalidField": "{field} — {reason}", + "moreIssues": "{message} (另有 {count} 項)" }, "form": { "moveUp": "上移",