Ver Fonte

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.
Zane há 1 dia atrás
pai
commit
02002dc1c3

+ 33 - 0
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<ClientRecord[]> {
+  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)),
+  });
+}

+ 36 - 1
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')} <QuestionCircleOutlined aria-hidden="true" />
               </Tooltip>
             }
+            transform={{
+              input: (value) => csv(typeof value === 'string' ? value : ''),
+              output: (value) => (Array.isArray(value) ? value.join(',') : ''),
+            }}
           >
-            <Input placeholder="email address" />
+            <Select
+              mode="tags"
+              tokenSeparators={[',']}
+              allowClear
+              loading={clientsLoading}
+              placeholder={t('pages.xray.ruleForm.userPlaceholder')}
+              showSearch={{ optionFilterProp: 'label' }}
+              notFoundContent={
+                clientsLoading
+                  ? t('loading')
+                  : clientsError
+                    ? t('pages.xray.ruleForm.userLoadError')
+                    : t('pages.xray.ruleForm.userEmpty')
+              }
+              options={userOptions}
+            />
           </FormField>
 
           <FormField

+ 98 - 3
frontend/src/test/rule-form-preserve-fields.test.tsx

@@ -1,12 +1,16 @@
-import { describe, it, expect, vi } from 'vitest';
-import { fireEvent, screen } from '@testing-library/react';
+import { afterEach, describe, it, expect, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 
 import RuleFormModal from '@/pages/xray/routing/RuleFormModal';
+import { keys } from '@/api/queryKeys';
+import { HttpUtil, Msg } from '@/utils';
 
-import { renderWithProviders } from './test-utils';
+import { chooseSelectOption, renderWithProviders } from './test-utils';
 
 describe('RuleFormModal edit preserves unsurfaced fields', () => {
+  afterEach(() => vi.restoreAllMocks());
+
   it('keeps a field the form does not surface (ruleTag) when saving an edit', () => {
     const onConfirm = vi.fn();
     const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -30,4 +34,95 @@ describe('RuleFormModal edit preserves unsurfaced fields', () => {
     expect(onConfirm).toHaveBeenCalledTimes(1);
     expect(onConfirm.mock.calls[0][0]).toMatchObject({ ruleTag: 'my-tag' });
   });
+
+  it('selects existing clients for the user criterion', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValue(
+      new Msg(true, '', [
+        { id: 1, email: '[email protected]' },
+        { id: 2, email: '[email protected]' },
+      ]),
+    );
+    const onConfirm = vi.fn();
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+    renderWithProviders(
+      <RuleFormModal
+        open
+        rule={null}
+        inboundTags={[]}
+        outboundTags={['direct']}
+        balancerTags={[]}
+        onClose={vi.fn()}
+        onConfirm={onConfirm}
+      />,
+      { queryClient },
+    );
+
+    await waitFor(() =>
+      expect(HttpUtil.get).toHaveBeenCalledWith('/panel/api/clients/list', undefined, {
+        silent: true,
+      }),
+    );
+    await waitFor(() => expect(queryClient.getQueryData(keys.clients.all())).toHaveLength(2));
+    const userField = screen.getByLabelText('User');
+    expect(screen.getByText('Select users')).toBeTruthy();
+    chooseSelectOption(userField.id, '[email protected]');
+    chooseSelectOption(userField.id, '[email protected]');
+    fireEvent.click(screen.getByRole('button', { name: 'Create' }));
+
+    expect(onConfirm).toHaveBeenCalledWith(
+      expect.objectContaining({ user: ['[email protected]', '[email protected]'] }),
+    );
+  });
+
+  it('preserves a saved user that is no longer in the client list', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValue(
+      new Msg(true, '', [{ id: 1, email: '[email protected]' }]),
+    );
+    const onConfirm = vi.fn();
+
+    renderWithProviders(
+      <RuleFormModal
+        open
+        rule={{ type: 'field', user: ['[email protected]'], outboundTag: 'direct' }}
+        inboundTags={[]}
+        outboundTags={['direct']}
+        balancerTags={[]}
+        onClose={vi.fn()}
+        onConfirm={onConfirm}
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByText('[email protected]')).toBeTruthy());
+    fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
+
+    expect(onConfirm).toHaveBeenCalledWith(
+      expect.objectContaining({ user: ['[email protected]'] }),
+    );
+  });
+
+  it('accepts a custom user identifier that is not a client record', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValue(
+      new Msg(true, '', [{ id: 1, email: '[email protected]' }]),
+    );
+    const onConfirm = vi.fn();
+
+    renderWithProviders(
+      <RuleFormModal
+        open
+        rule={null}
+        inboundTags={[]}
+        outboundTags={['direct']}
+        balancerTags={[]}
+        onClose={vi.fn()}
+        onConfirm={onConfirm}
+      />,
+    );
+
+    const userField = screen.getByLabelText('User');
+    fireEvent.change(userField, { target: { value: 'office-proxy,' } });
+    fireEvent.click(screen.getByRole('button', { name: 'Create' }));
+
+    expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ user: ['office-proxy'] }));
+  });
 });

+ 3 - 0
internal/web/translation/ar-EG.json

@@ -1520,6 +1520,9 @@
         "attributes": "الخصائص",
         "value": "القيمة",
         "user": "المستخدم",
+        "userPlaceholder": "اختر المستخدمين",
+        "userEmpty": "لا يوجد مستخدمون متاحون",
+        "userLoadError": "تعذر تحميل المستخدمين",
         "inboundTags": "وسوم الواردات",
         "outboundTag": "وسم الصادر",
         "balancerTag": "وسم الموازن",

+ 3 - 0
internal/web/translation/en-US.json

@@ -1638,6 +1638,9 @@
         "attributes": "Attributes",
         "value": "Value",
         "user": "User",
+        "userPlaceholder": "Select users",
+        "userEmpty": "No users available",
+        "userLoadError": "Could not load users",
         "inboundTags": "Inbound tags",
         "outboundTag": "Outbound tag",
         "balancerTag": "Balancer tag",

+ 3 - 0
internal/web/translation/es-ES.json

@@ -1520,6 +1520,9 @@
         "attributes": "Atributos",
         "value": "Valor",
         "user": "Usuario",
+        "userPlaceholder": "Seleccionar usuarios",
+        "userEmpty": "No hay usuarios disponibles",
+        "userLoadError": "No se pudieron cargar los usuarios",
         "inboundTags": "Etiquetas de entradas",
         "outboundTag": "Etiqueta de salida",
         "balancerTag": "Etiqueta de balanceador",

+ 3 - 0
internal/web/translation/fa-IR.json

@@ -1520,6 +1520,9 @@
         "attributes": "صفت‌ها",
         "value": "مقدار",
         "user": "کاربر",
+        "userPlaceholder": "انتخاب کاربران",
+        "userEmpty": "هیچ کاربری موجود نیست",
+        "userLoadError": "بارگیری کاربران ناموفق بود",
         "inboundTags": "تگ‌های ورودی",
         "outboundTag": "تگ خروجی",
         "balancerTag": "تگ بالانسر",

+ 3 - 0
internal/web/translation/id-ID.json

@@ -1520,6 +1520,9 @@
         "attributes": "Atribut",
         "value": "Nilai",
         "user": "Pengguna",
+        "userPlaceholder": "Pilih pengguna",
+        "userEmpty": "Tidak ada pengguna tersedia",
+        "userLoadError": "Gagal memuat pengguna",
         "inboundTags": "Tag inbound",
         "outboundTag": "Tag outbound",
         "balancerTag": "Tag balancer",

+ 3 - 0
internal/web/translation/ja-JP.json

@@ -1520,6 +1520,9 @@
         "attributes": "属性",
         "value": "値",
         "user": "ユーザー",
+        "userPlaceholder": "ユーザーを選択",
+        "userEmpty": "利用可能なユーザーがいません",
+        "userLoadError": "ユーザーを読み込めませんでした",
         "inboundTags": "インバウンドタグ",
         "outboundTag": "アウトバウンドタグ",
         "balancerTag": "バランサータグ",

+ 3 - 0
internal/web/translation/pt-BR.json

@@ -1520,6 +1520,9 @@
         "attributes": "Atributos",
         "value": "Valor",
         "user": "Usuário",
+        "userPlaceholder": "Selecionar usuários",
+        "userEmpty": "Nenhum usuário disponível",
+        "userLoadError": "Falha ao carregar usuários",
         "inboundTags": "Tags de entradas",
         "outboundTag": "Tag de saída",
         "balancerTag": "Tag de balanceador",

+ 3 - 0
internal/web/translation/ru-RU.json

@@ -1520,6 +1520,9 @@
         "attributes": "Атрибуты",
         "value": "Значение",
         "user": "Пользователь",
+        "userPlaceholder": "Выберите пользователей",
+        "userEmpty": "Нет доступных пользователей",
+        "userLoadError": "Не удалось загрузить пользователей",
         "inboundTags": "Теги входящих",
         "outboundTag": "Тег исходящего",
         "balancerTag": "Тег балансировщика",

+ 3 - 0
internal/web/translation/tr-TR.json

@@ -1520,6 +1520,9 @@
         "attributes": "Öznitelikler",
         "value": "Değer",
         "user": "Kullanıcı",
+        "userPlaceholder": "Kullanıcıları seçin",
+        "userEmpty": "Kullanılabilir kullanıcı yok",
+        "userLoadError": "Kullanıcılar yüklenemedi",
         "inboundTags": "Gelen Bağlantı Etiketleri",
         "outboundTag": "Giden Bağlantı Etiketi",
         "balancerTag": "Dengeleyici Etiketi",

+ 3 - 0
internal/web/translation/uk-UA.json

@@ -1520,6 +1520,9 @@
         "attributes": "Атрибути",
         "value": "Значення",
         "user": "Користувач",
+        "userPlaceholder": "Виберіть користувачів",
+        "userEmpty": "Немає доступних користувачів",
+        "userLoadError": "Не вдалося завантажити користувачів",
         "inboundTags": "Теги вхідних",
         "outboundTag": "Тег вихідного",
         "balancerTag": "Тег балансувальника",

+ 3 - 0
internal/web/translation/vi-VN.json

@@ -1520,6 +1520,9 @@
         "attributes": "Thuộc tính",
         "value": "Giá trị",
         "user": "Người dùng",
+        "userPlaceholder": "Chọn người dùng",
+        "userEmpty": "Không có người dùng khả dụng",
+        "userLoadError": "Không thể tải người dùng",
         "inboundTags": "Tag inbound",
         "outboundTag": "Tag outbound",
         "balancerTag": "Tag balancer",

+ 3 - 0
internal/web/translation/zh-CN.json

@@ -1520,6 +1520,9 @@
         "attributes": "属性",
         "value": "值",
         "user": "用户",
+        "userPlaceholder": "选择用户",
+        "userEmpty": "暂无可用用户",
+        "userLoadError": "无法加载用户",
         "inboundTags": "入站标签",
         "outboundTag": "出站标签",
         "balancerTag": "均衡器标签",

+ 3 - 0
internal/web/translation/zh-TW.json

@@ -1520,6 +1520,9 @@
         "attributes": "屬性",
         "value": "值",
         "user": "使用者",
+        "userPlaceholder": "選擇使用者",
+        "userEmpty": "暫無可用使用者",
+        "userLoadError": "無法載入使用者",
         "inboundTags": "入站標籤",
         "outboundTag": "出站標籤",
         "balancerTag": "均衡器標籤",