mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 20:07:13 +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 { 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
|
||||
|
||||
@@ -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: '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'] }));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user