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.
This commit is contained in:
Zane
2026-08-23 05:13:34 +08:00
committed by GitHub
parent 326009e9d3
commit 02002dc1c3
16 changed files with 206 additions and 4 deletions
@@ -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)),
});
}
@@ -6,6 +6,7 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { InputAddon } from '@/components/ui'; import { InputAddon } from '@/components/ui';
import { GeoTokenInput } from '@/components/geodata'; import { GeoTokenInput } from '@/components/geodata';
import { FormField } from '@/components/form/rhf'; import { FormField } from '@/components/form/rhf';
import { useClientOptions } from '@/api/queries/useClientOptions';
import { useInboundOptions } from '@/api/queries/useInboundOptions'; import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray'; import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers'; import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
@@ -82,6 +83,21 @@ export default function RuleFormModal({
const { data: inboundOptions } = useInboundOptions(); const { data: inboundOptions } = useInboundOptions();
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -287,8 +303,27 @@ export default function RuleFormModal({
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" /> {t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip> </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>
<FormField <FormField
@@ -1,12 +1,16 @@
import { describe, it, expect, vi } from 'vitest'; import { afterEach, describe, it, expect, vi } from 'vitest';
import { fireEvent, screen } from '@testing-library/react'; import { fireEvent, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import RuleFormModal from '@/pages/xray/routing/RuleFormModal'; 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', () => { describe('RuleFormModal edit preserves unsurfaced fields', () => {
afterEach(() => vi.restoreAllMocks());
it('keeps a field the form does not surface (ruleTag) when saving an edit', () => { it('keeps a field the form does not surface (ruleTag) when saving an edit', () => {
const onConfirm = vi.fn(); const onConfirm = vi.fn();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -30,4 +34,95 @@ describe('RuleFormModal edit preserves unsurfaced fields', () => {
expect(onConfirm).toHaveBeenCalledTimes(1); expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onConfirm.mock.calls[0][0]).toMatchObject({ ruleTag: 'my-tag' }); 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: 'alice@example.com' },
{ id: 2, email: 'bob@example.com' },
]),
);
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, 'alice@example.com');
chooseSelectOption(userField.id, 'bob@example.com');
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({ user: ['alice@example.com', 'bob@example.com'] }),
);
});
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: 'alice@example.com' }]),
);
const onConfirm = vi.fn();
renderWithProviders(
<RuleFormModal
open
rule={{ type: 'field', user: ['removed@example.com'], outboundTag: 'direct' }}
inboundTags={[]}
outboundTags={['direct']}
balancerTags={[]}
onClose={vi.fn()}
onConfirm={onConfirm}
/>,
);
await waitFor(() => expect(screen.getByText('removed@example.com')).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({ user: ['removed@example.com'] }),
);
});
it('accepts a custom user identifier that is not a client record', async () => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(
new Msg(true, '', [{ id: 1, email: 'alice@example.com' }]),
);
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
View File
@@ -1520,6 +1520,9 @@
"attributes": "الخصائص", "attributes": "الخصائص",
"value": "القيمة", "value": "القيمة",
"user": "المستخدم", "user": "المستخدم",
"userPlaceholder": "اختر المستخدمين",
"userEmpty": "لا يوجد مستخدمون متاحون",
"userLoadError": "تعذر تحميل المستخدمين",
"inboundTags": "وسوم الواردات", "inboundTags": "وسوم الواردات",
"outboundTag": "وسم الصادر", "outboundTag": "وسم الصادر",
"balancerTag": "وسم الموازن", "balancerTag": "وسم الموازن",
+3
View File
@@ -1638,6 +1638,9 @@
"attributes": "Attributes", "attributes": "Attributes",
"value": "Value", "value": "Value",
"user": "User", "user": "User",
"userPlaceholder": "Select users",
"userEmpty": "No users available",
"userLoadError": "Could not load users",
"inboundTags": "Inbound tags", "inboundTags": "Inbound tags",
"outboundTag": "Outbound tag", "outboundTag": "Outbound tag",
"balancerTag": "Balancer tag", "balancerTag": "Balancer tag",
+3
View File
@@ -1520,6 +1520,9 @@
"attributes": "Atributos", "attributes": "Atributos",
"value": "Valor", "value": "Valor",
"user": "Usuario", "user": "Usuario",
"userPlaceholder": "Seleccionar usuarios",
"userEmpty": "No hay usuarios disponibles",
"userLoadError": "No se pudieron cargar los usuarios",
"inboundTags": "Etiquetas de entradas", "inboundTags": "Etiquetas de entradas",
"outboundTag": "Etiqueta de salida", "outboundTag": "Etiqueta de salida",
"balancerTag": "Etiqueta de balanceador", "balancerTag": "Etiqueta de balanceador",
+3
View File
@@ -1520,6 +1520,9 @@
"attributes": "صفت‌ها", "attributes": "صفت‌ها",
"value": "مقدار", "value": "مقدار",
"user": "کاربر", "user": "کاربر",
"userPlaceholder": "انتخاب کاربران",
"userEmpty": "هیچ کاربری موجود نیست",
"userLoadError": "بارگیری کاربران ناموفق بود",
"inboundTags": "تگ‌های ورودی", "inboundTags": "تگ‌های ورودی",
"outboundTag": "تگ خروجی", "outboundTag": "تگ خروجی",
"balancerTag": "تگ بالانسر", "balancerTag": "تگ بالانسر",
+3
View File
@@ -1520,6 +1520,9 @@
"attributes": "Atribut", "attributes": "Atribut",
"value": "Nilai", "value": "Nilai",
"user": "Pengguna", "user": "Pengguna",
"userPlaceholder": "Pilih pengguna",
"userEmpty": "Tidak ada pengguna tersedia",
"userLoadError": "Gagal memuat pengguna",
"inboundTags": "Tag inbound", "inboundTags": "Tag inbound",
"outboundTag": "Tag outbound", "outboundTag": "Tag outbound",
"balancerTag": "Tag balancer", "balancerTag": "Tag balancer",
+3
View File
@@ -1520,6 +1520,9 @@
"attributes": "属性", "attributes": "属性",
"value": "値", "value": "値",
"user": "ユーザー", "user": "ユーザー",
"userPlaceholder": "ユーザーを選択",
"userEmpty": "利用可能なユーザーがいません",
"userLoadError": "ユーザーを読み込めませんでした",
"inboundTags": "インバウンドタグ", "inboundTags": "インバウンドタグ",
"outboundTag": "アウトバウンドタグ", "outboundTag": "アウトバウンドタグ",
"balancerTag": "バランサータグ", "balancerTag": "バランサータグ",
+3
View File
@@ -1520,6 +1520,9 @@
"attributes": "Atributos", "attributes": "Atributos",
"value": "Valor", "value": "Valor",
"user": "Usuário", "user": "Usuário",
"userPlaceholder": "Selecionar usuários",
"userEmpty": "Nenhum usuário disponível",
"userLoadError": "Falha ao carregar usuários",
"inboundTags": "Tags de entradas", "inboundTags": "Tags de entradas",
"outboundTag": "Tag de saída", "outboundTag": "Tag de saída",
"balancerTag": "Tag de balanceador", "balancerTag": "Tag de balanceador",
+3
View File
@@ -1520,6 +1520,9 @@
"attributes": "Атрибуты", "attributes": "Атрибуты",
"value": "Значение", "value": "Значение",
"user": "Пользователь", "user": "Пользователь",
"userPlaceholder": "Выберите пользователей",
"userEmpty": "Нет доступных пользователей",
"userLoadError": "Не удалось загрузить пользователей",
"inboundTags": "Теги входящих", "inboundTags": "Теги входящих",
"outboundTag": "Тег исходящего", "outboundTag": "Тег исходящего",
"balancerTag": "Тег балансировщика", "balancerTag": "Тег балансировщика",
+3
View File
@@ -1520,6 +1520,9 @@
"attributes": "Öznitelikler", "attributes": "Öznitelikler",
"value": "Değer", "value": "Değer",
"user": "Kullanıcı", "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", "inboundTags": "Gelen Bağlantı Etiketleri",
"outboundTag": "Giden Bağlantı Etiketi", "outboundTag": "Giden Bağlantı Etiketi",
"balancerTag": "Dengeleyici Etiketi", "balancerTag": "Dengeleyici Etiketi",
+3
View File
@@ -1520,6 +1520,9 @@
"attributes": "Атрибути", "attributes": "Атрибути",
"value": "Значення", "value": "Значення",
"user": "Користувач", "user": "Користувач",
"userPlaceholder": "Виберіть користувачів",
"userEmpty": "Немає доступних користувачів",
"userLoadError": "Не вдалося завантажити користувачів",
"inboundTags": "Теги вхідних", "inboundTags": "Теги вхідних",
"outboundTag": "Тег вихідного", "outboundTag": "Тег вихідного",
"balancerTag": "Тег балансувальника", "balancerTag": "Тег балансувальника",
+3
View File
@@ -1520,6 +1520,9 @@
"attributes": "Thuộc tính", "attributes": "Thuộc tính",
"value": "Giá trị", "value": "Giá trị",
"user": "Người dùng", "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", "inboundTags": "Tag inbound",
"outboundTag": "Tag outbound", "outboundTag": "Tag outbound",
"balancerTag": "Tag balancer", "balancerTag": "Tag balancer",
+3
View File
@@ -1520,6 +1520,9 @@
"attributes": "属性", "attributes": "属性",
"value": "值", "value": "值",
"user": "用户", "user": "用户",
"userPlaceholder": "选择用户",
"userEmpty": "暂无可用用户",
"userLoadError": "无法加载用户",
"inboundTags": "入站标签", "inboundTags": "入站标签",
"outboundTag": "出站标签", "outboundTag": "出站标签",
"balancerTag": "均衡器标签", "balancerTag": "均衡器标签",
+3
View File
@@ -1520,6 +1520,9 @@
"attributes": "屬性", "attributes": "屬性",
"value": "值", "value": "值",
"user": "使用者", "user": "使用者",
"userPlaceholder": "選擇使用者",
"userEmpty": "暫無可用使用者",
"userLoadError": "無法載入使用者",
"inboundTags": "入站標籤", "inboundTags": "入站標籤",
"outboundTag": "出站標籤", "outboundTag": "出站標籤",
"balancerTag": "均衡器標籤", "balancerTag": "均衡器標籤",