mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15:01 +03:00
e2184777a0
* fix(frontend): make the panel usable with screen readers * fix(frontend): keep mobile tab names and the inbound expiry hint Review of #6907 found two gaps in the screen-reader pass: - On phone-width screens catTabLabel renders only the icon and names the tab by putting aria-label on it, but callers now pass that icon with aria-hidden for the desktop label, so every Settings, Xray and Host category tab lost its name under TalkBack. catTabLabel now clears aria-hidden on the icon it uses as the name. - DateTimePicker forwarded id and aria-label but not aria-describedby, so the inbound expiry hint ("leave blank to never expire") was never attached. It now forwards it on both the Gregorian and Jalali input.
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
|
|
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
|
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
|
|
|
import { InputAddon } from '@/components/ui';
|
|
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
|
import {
|
|
DnsQueryStrategySchema,
|
|
DnsServerObjectInnerSchema,
|
|
DnsServerObjectSchema,
|
|
isEncryptedDnsAddress,
|
|
type DnsServerObject,
|
|
} from '@/schemas/dns';
|
|
|
|
export type DnsServerValue =
|
|
| string
|
|
| (DnsServerObject & {
|
|
expectIPs?: string[];
|
|
[key: string]: unknown;
|
|
});
|
|
|
|
interface DnsServerModalProps {
|
|
open: boolean;
|
|
server: DnsServerValue | null;
|
|
isEdit: boolean;
|
|
onClose: () => void;
|
|
onConfirm: (value: DnsServerValue) => void;
|
|
}
|
|
|
|
const STRATEGIES = DnsQueryStrategySchema.options;
|
|
|
|
type DnsServerForm = {
|
|
id: string;
|
|
address: string;
|
|
port: number;
|
|
domains: string[];
|
|
expectedIPs: string[];
|
|
unexpectedIPs: string[];
|
|
queryStrategy: string;
|
|
skipFallback: boolean;
|
|
disableCache: boolean;
|
|
finalQuery: boolean;
|
|
tag: string;
|
|
clientIP: string;
|
|
serveStale: boolean;
|
|
serveExpiredTTL: number;
|
|
timeoutMs: number;
|
|
};
|
|
|
|
function defaultFormValues(): DnsServerForm {
|
|
return {
|
|
id: '',
|
|
address: 'localhost',
|
|
port: 53,
|
|
domains: [],
|
|
expectedIPs: [],
|
|
unexpectedIPs: [],
|
|
queryStrategy: 'UseIP',
|
|
skipFallback: false,
|
|
disableCache: false,
|
|
finalQuery: false,
|
|
tag: '',
|
|
clientIP: '',
|
|
serveStale: false,
|
|
serveExpiredTTL: 0,
|
|
timeoutMs: 4000,
|
|
};
|
|
}
|
|
|
|
function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
|
|
if (server == null) return defaultFormValues();
|
|
if (typeof server === 'string') return { ...defaultFormValues(), address: server };
|
|
const parsed = DnsServerObjectSchema.safeParse(server);
|
|
const data = parsed.success ? parsed.data : null;
|
|
return {
|
|
...defaultFormValues(),
|
|
...(data ?? {}),
|
|
address: (data?.address ?? server.address) || 'localhost',
|
|
domains: data?.domains ?? server.domains ?? [],
|
|
expectedIPs: data?.expectedIPs ?? server.expectedIPs ?? server.expectIPs ?? [],
|
|
unexpectedIPs: data?.unexpectedIPs ?? server.unexpectedIPs ?? [],
|
|
queryStrategy: data?.queryStrategy ?? server.queryStrategy ?? 'UseIP',
|
|
skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
|
|
disableCache: data?.disableCache ?? server.disableCache ?? false,
|
|
finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
|
|
id: data?.id ?? server.id ?? '',
|
|
tag: data?.tag ?? server.tag ?? '',
|
|
clientIP: data?.clientIP ?? server.clientIP ?? '',
|
|
serveStale: data?.serveStale ?? server.serveStale ?? false,
|
|
serveExpiredTTL: data?.serveExpiredTTL ?? server.serveExpiredTTL ?? 0,
|
|
timeoutMs: data?.timeoutMs ?? server.timeoutMs ?? 4000,
|
|
};
|
|
}
|
|
|
|
function valuesToWire(values: DnsServerForm): DnsServerValue {
|
|
const isPlain =
|
|
values.domains.length === 0 &&
|
|
values.expectedIPs.length === 0 &&
|
|
values.unexpectedIPs.length === 0 &&
|
|
values.port === 53 &&
|
|
values.queryStrategy === 'UseIP' &&
|
|
values.skipFallback === false &&
|
|
values.disableCache === false &&
|
|
values.finalQuery === false &&
|
|
!values.id &&
|
|
!values.tag &&
|
|
!values.clientIP &&
|
|
values.serveStale === false &&
|
|
values.serveExpiredTTL === 0 &&
|
|
values.timeoutMs === 4000;
|
|
if (isPlain) return values.address;
|
|
|
|
const out: Record<string, unknown> = {
|
|
address: values.address,
|
|
domains: values.domains.filter(Boolean),
|
|
expectedIPs: values.expectedIPs.filter(Boolean),
|
|
unexpectedIPs: values.unexpectedIPs.filter(Boolean),
|
|
queryStrategy: values.queryStrategy,
|
|
skipFallback: values.skipFallback,
|
|
disableCache: values.disableCache,
|
|
finalQuery: values.finalQuery,
|
|
serveStale: values.serveStale,
|
|
serveExpiredTTL: values.serveExpiredTTL,
|
|
timeoutMs: values.timeoutMs,
|
|
};
|
|
if (!isEncryptedDnsAddress(values.address)) out.port = values.port;
|
|
if (values.id) out.id = values.id;
|
|
if (values.tag) out.tag = values.tag;
|
|
if (values.clientIP) out.clientIP = values.clientIP;
|
|
return out as DnsServerValue;
|
|
}
|
|
|
|
const shape = DnsServerObjectInnerSchema.shape;
|
|
|
|
export default function DnsServerModal({
|
|
open,
|
|
server,
|
|
isEdit,
|
|
onClose,
|
|
onConfirm,
|
|
}: DnsServerModalProps) {
|
|
const { t } = useTranslation();
|
|
const methods = useForm<DnsServerForm>({
|
|
defaultValues: defaultFormValues(),
|
|
});
|
|
const address = useWatch({ control: methods.control, name: 'address' }) ?? '';
|
|
// Xray ignores port for DoH/DoHL/DoQL, so valuesToWire never stores one:
|
|
// offering the field there discards whatever is typed into it.
|
|
const portApplies = !isEncryptedDnsAddress(address);
|
|
const domains = useWatch({ control: methods.control, name: 'domains' }) ?? [];
|
|
const expectedIPs = useWatch({ control: methods.control, name: 'expectedIPs' }) ?? [];
|
|
const unexpectedIPs = useWatch({ control: methods.control, name: 'unexpectedIPs' }) ?? [];
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
methods.reset(valuesFromServer(server));
|
|
}, [open, server, methods]);
|
|
|
|
const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
title={title}
|
|
okText={t('confirm')}
|
|
cancelText={t('close')}
|
|
mask={{ closable: false }}
|
|
onOk={methods.handleSubmit((values) => onConfirm(valuesToWire(values)))}
|
|
onCancel={onClose}
|
|
>
|
|
<FormProvider {...methods}>
|
|
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
|
<FormField
|
|
label={t('pages.inbounds.address')}
|
|
name="address"
|
|
rules={{ validate: rhfZodValidate(shape.address) }}
|
|
>
|
|
<Input />
|
|
</FormField>
|
|
{portApplies && (
|
|
<FormField
|
|
label={t('pages.inbounds.port')}
|
|
name="port"
|
|
rules={{ validate: rhfZodValidate(shape.port) }}
|
|
>
|
|
<InputNumber min={1} max={65535} />
|
|
</FormField>
|
|
)}
|
|
<FormField
|
|
label={t('pages.xray.dns.serverId')}
|
|
tooltip={t('pages.xray.dns.serverIdDesc')}
|
|
name="id"
|
|
>
|
|
<Input />
|
|
</FormField>
|
|
<FormField label={t('pages.xray.dns.tag')} name="tag">
|
|
<Input />
|
|
</FormField>
|
|
<FormField label={t('pages.xray.dns.clientIp')} name="clientIP">
|
|
<Input />
|
|
</FormField>
|
|
<FormField label={t('pages.xray.dns.strategy')} name="queryStrategy">
|
|
<Select
|
|
style={{ width: '100%' }}
|
|
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
|
|
/>
|
|
</FormField>
|
|
<FormField
|
|
label={t('pages.xray.dns.timeoutMs')}
|
|
name="timeoutMs"
|
|
rules={{ validate: rhfZodValidate(shape.timeoutMs) }}
|
|
>
|
|
<InputNumber min={0} step={500} />
|
|
</FormField>
|
|
|
|
<Divider style={{ margin: '5px 0' }} />
|
|
|
|
<Form.Item label={t('pages.xray.dns.domains')}>
|
|
<Button
|
|
size="small"
|
|
type="primary"
|
|
icon={<PlusOutlined aria-hidden />}
|
|
aria-label={t('add')}
|
|
onClick={() => methods.setValue('domains', [...domains, ''])}
|
|
/>
|
|
{domains.map((_, i) => (
|
|
<Space.Compact key={i} block style={{ marginTop: 4 }}>
|
|
<FormField name={`domains.${i}`} noStyle>
|
|
<Input aria-label={t('pages.xray.dns.domains')} />
|
|
</FormField>
|
|
<InputAddon
|
|
ariaLabel={t('remove')}
|
|
onClick={() =>
|
|
methods.setValue(
|
|
'domains',
|
|
domains.filter((__, idx) => idx !== i),
|
|
)
|
|
}
|
|
>
|
|
<MinusOutlined aria-hidden />
|
|
</InputAddon>
|
|
</Space.Compact>
|
|
))}
|
|
</Form.Item>
|
|
|
|
<Form.Item label={t('pages.xray.dns.expectIPs')}>
|
|
<Button
|
|
size="small"
|
|
type="primary"
|
|
icon={<PlusOutlined aria-hidden />}
|
|
aria-label={t('add')}
|
|
onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])}
|
|
/>
|
|
{expectedIPs.map((_, i) => (
|
|
<Space.Compact key={i} block style={{ marginTop: 4 }}>
|
|
<FormField name={`expectedIPs.${i}`} noStyle>
|
|
<Input aria-label={t('pages.xray.dns.expectIPs')} />
|
|
</FormField>
|
|
<InputAddon
|
|
ariaLabel={t('remove')}
|
|
onClick={() =>
|
|
methods.setValue(
|
|
'expectedIPs',
|
|
expectedIPs.filter((__, idx) => idx !== i),
|
|
)
|
|
}
|
|
>
|
|
<MinusOutlined aria-hidden />
|
|
</InputAddon>
|
|
</Space.Compact>
|
|
))}
|
|
</Form.Item>
|
|
|
|
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
|
|
<Button
|
|
size="small"
|
|
type="primary"
|
|
icon={<PlusOutlined aria-hidden />}
|
|
aria-label={t('add')}
|
|
onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])}
|
|
/>
|
|
{unexpectedIPs.map((_, i) => (
|
|
<Space.Compact key={i} block style={{ marginTop: 4 }}>
|
|
<FormField name={`unexpectedIPs.${i}`} noStyle>
|
|
<Input aria-label={t('pages.xray.dns.unexpectIPs')} />
|
|
</FormField>
|
|
<InputAddon
|
|
ariaLabel={t('remove')}
|
|
onClick={() =>
|
|
methods.setValue(
|
|
'unexpectedIPs',
|
|
unexpectedIPs.filter((__, idx) => idx !== i),
|
|
)
|
|
}
|
|
>
|
|
<MinusOutlined aria-hidden />
|
|
</InputAddon>
|
|
</Space.Compact>
|
|
))}
|
|
</Form.Item>
|
|
|
|
<Divider style={{ margin: '5px 0' }} />
|
|
|
|
<FormField
|
|
label={t('pages.xray.dns.skipFallback')}
|
|
name="skipFallback"
|
|
valueProp="checked"
|
|
>
|
|
<Switch />
|
|
</FormField>
|
|
<FormField label={t('pages.xray.dns.finalQuery')} name="finalQuery" valueProp="checked">
|
|
<Switch />
|
|
</FormField>
|
|
<FormField
|
|
label={t('pages.xray.dns.disableCache')}
|
|
name="disableCache"
|
|
valueProp="checked"
|
|
>
|
|
<Switch />
|
|
</FormField>
|
|
<FormField label={t('pages.xray.dns.serveStale')} name="serveStale" valueProp="checked">
|
|
<Switch />
|
|
</FormField>
|
|
<FormField label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
|
|
<InputNumber min={0} step={60} />
|
|
</FormField>
|
|
</Form>
|
|
</FormProvider>
|
|
</Modal>
|
|
);
|
|
}
|