Files
3x-ui/frontend/src/pages/xray/dns/DnsServerModal.tsx
T
Alireza Ahmadi e2184777a0 fix(frontend): make the panel usable with screen readers (#6907)
* 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.
2026-10-23 18:29:47 +02:00

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>
);
}