mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 11:57:15 +00:00
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:
@@ -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'] }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1520,6 +1520,9 @@
|
|||||||
"attributes": "الخصائص",
|
"attributes": "الخصائص",
|
||||||
"value": "القيمة",
|
"value": "القيمة",
|
||||||
"user": "المستخدم",
|
"user": "المستخدم",
|
||||||
|
"userPlaceholder": "اختر المستخدمين",
|
||||||
|
"userEmpty": "لا يوجد مستخدمون متاحون",
|
||||||
|
"userLoadError": "تعذر تحميل المستخدمين",
|
||||||
"inboundTags": "وسوم الواردات",
|
"inboundTags": "وسوم الواردات",
|
||||||
"outboundTag": "وسم الصادر",
|
"outboundTag": "وسم الصادر",
|
||||||
"balancerTag": "وسم الموازن",
|
"balancerTag": "وسم الموازن",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1520,6 +1520,9 @@
|
|||||||
"attributes": "صفتها",
|
"attributes": "صفتها",
|
||||||
"value": "مقدار",
|
"value": "مقدار",
|
||||||
"user": "کاربر",
|
"user": "کاربر",
|
||||||
|
"userPlaceholder": "انتخاب کاربران",
|
||||||
|
"userEmpty": "هیچ کاربری موجود نیست",
|
||||||
|
"userLoadError": "بارگیری کاربران ناموفق بود",
|
||||||
"inboundTags": "تگهای ورودی",
|
"inboundTags": "تگهای ورودی",
|
||||||
"outboundTag": "تگ خروجی",
|
"outboundTag": "تگ خروجی",
|
||||||
"balancerTag": "تگ بالانسر",
|
"balancerTag": "تگ بالانسر",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1520,6 +1520,9 @@
|
|||||||
"attributes": "属性",
|
"attributes": "属性",
|
||||||
"value": "値",
|
"value": "値",
|
||||||
"user": "ユーザー",
|
"user": "ユーザー",
|
||||||
|
"userPlaceholder": "ユーザーを選択",
|
||||||
|
"userEmpty": "利用可能なユーザーがいません",
|
||||||
|
"userLoadError": "ユーザーを読み込めませんでした",
|
||||||
"inboundTags": "インバウンドタグ",
|
"inboundTags": "インバウンドタグ",
|
||||||
"outboundTag": "アウトバウンドタグ",
|
"outboundTag": "アウトバウンドタグ",
|
||||||
"balancerTag": "バランサータグ",
|
"balancerTag": "バランサータグ",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1520,6 +1520,9 @@
|
|||||||
"attributes": "Атрибуты",
|
"attributes": "Атрибуты",
|
||||||
"value": "Значение",
|
"value": "Значение",
|
||||||
"user": "Пользователь",
|
"user": "Пользователь",
|
||||||
|
"userPlaceholder": "Выберите пользователей",
|
||||||
|
"userEmpty": "Нет доступных пользователей",
|
||||||
|
"userLoadError": "Не удалось загрузить пользователей",
|
||||||
"inboundTags": "Теги входящих",
|
"inboundTags": "Теги входящих",
|
||||||
"outboundTag": "Тег исходящего",
|
"outboundTag": "Тег исходящего",
|
||||||
"balancerTag": "Тег балансировщика",
|
"balancerTag": "Тег балансировщика",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1520,6 +1520,9 @@
|
|||||||
"attributes": "Атрибути",
|
"attributes": "Атрибути",
|
||||||
"value": "Значення",
|
"value": "Значення",
|
||||||
"user": "Користувач",
|
"user": "Користувач",
|
||||||
|
"userPlaceholder": "Виберіть користувачів",
|
||||||
|
"userEmpty": "Немає доступних користувачів",
|
||||||
|
"userLoadError": "Не вдалося завантажити користувачів",
|
||||||
"inboundTags": "Теги вхідних",
|
"inboundTags": "Теги вхідних",
|
||||||
"outboundTag": "Тег вихідного",
|
"outboundTag": "Тег вихідного",
|
||||||
"balancerTag": "Тег балансувальника",
|
"balancerTag": "Тег балансувальника",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1520,6 +1520,9 @@
|
|||||||
"attributes": "属性",
|
"attributes": "属性",
|
||||||
"value": "值",
|
"value": "值",
|
||||||
"user": "用户",
|
"user": "用户",
|
||||||
|
"userPlaceholder": "选择用户",
|
||||||
|
"userEmpty": "暂无可用用户",
|
||||||
|
"userLoadError": "无法加载用户",
|
||||||
"inboundTags": "入站标签",
|
"inboundTags": "入站标签",
|
||||||
"outboundTag": "出站标签",
|
"outboundTag": "出站标签",
|
||||||
"balancerTag": "均衡器标签",
|
"balancerTag": "均衡器标签",
|
||||||
|
|||||||
@@ -1520,6 +1520,9 @@
|
|||||||
"attributes": "屬性",
|
"attributes": "屬性",
|
||||||
"value": "值",
|
"value": "值",
|
||||||
"user": "使用者",
|
"user": "使用者",
|
||||||
|
"userPlaceholder": "選擇使用者",
|
||||||
|
"userEmpty": "暫無可用使用者",
|
||||||
|
"userLoadError": "無法載入使用者",
|
||||||
"inboundTags": "入站標籤",
|
"inboundTags": "入站標籤",
|
||||||
"outboundTag": "出站標籤",
|
"outboundTag": "出站標籤",
|
||||||
"balancerTag": "均衡器標籤",
|
"balancerTag": "均衡器標籤",
|
||||||
|
|||||||
Reference in New Issue
Block a user