From 02002dc1c3048d8ab70478d7bb93157159462bb6 Mon Sep 17 00:00:00 2001 From: Zane Date: Sun, 23 Aug 2026 05:13:34 +0800 Subject: [PATCH] feat(routing): add client picker to user rules (#6271) * feat(routing): add client picker to user rules Replace the free-text user criterion with a searchable multi-select backed by existing panel clients. Preserve saved values that no longer exist so editing legacy rules remains lossless. * feat(routing): polish user picker states Align the routing user selector with the inbound-tag multi-select, including search, clear, loading, empty, and error states. Localize the new copy across every supported locale and cover legacy saved users with a regression test. * fix(routing): keep custom user identifiers Use tags mode with comma tokenization so the user picker suggests panel clients without rejecting HTTP, Mixed, or raw-template identifiers. Restore the comma hint and cover custom entries with a regression test. --- frontend/src/api/queries/useClientOptions.ts | 33 ++++++ .../src/pages/xray/routing/RuleFormModal.tsx | 37 ++++++- .../test/rule-form-preserve-fields.test.tsx | 101 +++++++++++++++++- internal/web/translation/ar-EG.json | 3 + internal/web/translation/en-US.json | 3 + internal/web/translation/es-ES.json | 3 + internal/web/translation/fa-IR.json | 3 + internal/web/translation/id-ID.json | 3 + internal/web/translation/ja-JP.json | 3 + internal/web/translation/pt-BR.json | 3 + internal/web/translation/ru-RU.json | 3 + internal/web/translation/tr-TR.json | 3 + internal/web/translation/uk-UA.json | 3 + internal/web/translation/vi-VN.json | 3 + internal/web/translation/zh-CN.json | 3 + internal/web/translation/zh-TW.json | 3 + 16 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 frontend/src/api/queries/useClientOptions.ts diff --git a/frontend/src/api/queries/useClientOptions.ts b/frontend/src/api/queries/useClientOptions.ts new file mode 100644 index 000000000..26968a724 --- /dev/null +++ b/frontend/src/api/queries/useClientOptions.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { z } from 'zod'; + +import { HttpUtil } from '@/utils'; +import { parseMsg } from '@/utils/zodValidate'; +import { keys } from '@/api/queryKeys'; +import { ClientRecordSchema, type ClientRecord } from '@/schemas/client'; + +const ClientRecordListSchema = z + .array(ClientRecordSchema) + .nullable() + .transform((value) => value ?? []); + +async function fetchClients(): Promise { + const msg = await HttpUtil.get('/panel/api/clients/list', undefined, { silent: true }); + if (!msg?.success) throw new Error(msg?.msg || 'Failed to load clients'); + const validated = parseMsg(msg, ClientRecordListSchema, 'clients/list'); + return validated.obj ?? []; +} + +export function useClientOptions(enabled = true) { + return useQuery({ + queryKey: keys.clients.all(), + queryFn: fetchClients, + enabled, + staleTime: 30_000, + select: (clients) => + clients + .map((client) => client.email.trim()) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)), + }); +} diff --git a/frontend/src/pages/xray/routing/RuleFormModal.tsx b/frontend/src/pages/xray/routing/RuleFormModal.tsx index 48e602e5d..08c1189f3 100644 --- a/frontend/src/pages/xray/routing/RuleFormModal.tsx +++ b/frontend/src/pages/xray/routing/RuleFormModal.tsx @@ -6,6 +6,7 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { InputAddon } from '@/components/ui'; import { GeoTokenInput } from '@/components/geodata'; import { FormField } from '@/components/form/rhf'; +import { useClientOptions } from '@/api/queries/useClientOptions'; import { useInboundOptions } from '@/api/queries/useInboundOptions'; import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray'; import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers'; @@ -82,6 +83,21 @@ export default function RuleFormModal({ const { data: inboundOptions } = useInboundOptions(); const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]); + const { + data: clientEmails = [], + isFetching: clientsLoading, + isError: clientsError, + } = useClientOptions(open); + const user = useWatch({ control: methods.control, name: 'user' }) ?? ''; + const selectedUsers = useMemo(() => csv(user), [user]); + const userOptions = useMemo( + () => + [...new Set([...clientEmails, ...selectedUsers])].map((email) => ({ + value: email, + label: email, + })), + [clientEmails, selectedUsers], + ); useEffect(() => { if (!open) return; @@ -287,8 +303,27 @@ export default function RuleFormModal({ {t('pages.xray.ruleForm.user')}