Files
3x-ui/frontend/src/pages/settings/SettingsPage.tsx
T
Alireza Ahmadi 1bdb517b88 fix(a11y): silence live stats and make Select tags removable for screen readers
Ant Design's Spin wraps its content in aria-live="polite", so every
WebSocket tick on the dashboard, inbounds, clients and nodes pages was
announced, and only the changed number was read, with no label.
Pass aria-live="off" on every Spin that wraps content; aria-busy still
signals loading. Each vital tile is now a group named by its metric,
so "47.2 %" is read as CPU.

rc-select renders each tag's remove "x" as an aria-hidden span with no
tabIndex, so screen-reader users could only Backspace the last value
away, never remove one SNI or inbound from the middle.
selectTagRender swaps in a real button named "Remove <value>" and
moves focus to the next tag after removal. It is applied to every
tags/multiple Select. maxTagCount="responsive" is dropped because it
hid selected inbounds behind "+N", where no screen reader could reach
them.

Two oxlint rules (antd-a11y/spin-content-not-live and
antd-a11y/select-tag-remove) keep new code from regressing either fix.
2026-11-01 16:49:19 +01:00

315 lines
10 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router';
import {
Alert,
Button,
Card,
Col,
ConfigProvider,
FloatButton,
Layout,
Modal,
Row,
Space,
Spin,
message,
} from 'antd';
import { HttpUtil, PromiseUtil } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useAllSettings } from '@/api/queries/useAllSettings';
import { AllSettingSchema } from '@/schemas/setting';
import AppSidebar from '@/layouts/AppSidebar';
import GeneralTab from './GeneralTab';
import SecurityTab from './SecurityTab';
import TelegramTab from './TelegramTab';
import EmailTab from './EmailTab';
import DiscordTab from './DiscordTab';
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
import SubscriptionBalancersTab from './SubscriptionBalancersTab';
import './SettingsPage.css';
interface ApiMsg {
success?: boolean;
}
const tabSlugs = [
'general',
'security',
'telegram',
'email',
'discord',
'subscription',
'subscription-formats',
'subscription-balancers',
];
function isIp(h: string): boolean {
if (typeof h !== 'string') return false;
const v4 = h.split('.');
if (v4.length === 4 && v4.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) return true;
if (!h.includes(':') || h.includes(':::')) return false;
const parts = h.split('::');
if (parts.length > 2) return false;
const split = (s: string) => (s ? s.split(':').filter(Boolean) : []);
const head = split(parts[0]);
const tail = split(parts[1]);
const valid = (seg: string) => /^[0-9a-fA-F]{1,4}$/.test(seg);
if (![...head, ...tail].every(valid)) return false;
const groups = head.length + tail.length;
return parts.length === 2 ? groups < 8 : groups === 8;
}
function scrollTarget() {
return document.getElementById('content-layout') as HTMLElement;
}
export default function SettingsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const {
allSetting,
updateSetting,
fetched,
spinning,
setSpinning,
saveDisabled,
saveAll,
savePayload,
} = useAllSettings();
const [entryHost] = useState(() => window.location.hostname);
const [entryPort] = useState(() => window.location.port);
const [entryIsIP] = useState(() => isIp(window.location.hostname));
const [alertVisible, setAlertVisible] = useState(true);
const location = useLocation();
const slug = location.hash.replace(/^#/, '');
const activeSlug = tabSlugs.includes(slug) ? slug : 'general';
function rebuildUrlAfterRestart(): string {
const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = allSetting;
const newProtocol = webCertFile || webKeyFile ? 'https:' : 'http:';
let base = webBasePath ? webBasePath.replace(/^\//, '') : '';
if (base && !base.endsWith('/')) base += '/';
if (!entryIsIP) {
const url = new URL(window.location.href);
url.pathname = `/${base}panel/settings`;
url.protocol = newProtocol;
return url.toString();
}
let finalHost = entryHost;
let finalPort = entryPort || '';
if (webDomain && isIp(webDomain)) finalHost = webDomain;
if (webPort && Number(webPort) !== Number(entryPort)) finalPort = String(webPort);
const url = new URL(`${newProtocol}//${finalHost}`);
if (finalPort) url.port = finalPort;
url.pathname = `/${base}panel/settings`;
return url.toString();
}
async function onSave() {
const result = AllSettingSchema.safeParse(allSetting);
if (!result.success) {
const issue = result.error.issues[0];
const fieldPath = issue?.path.join('.') ?? 'value';
const msgKey = issue?.message ?? 'somethingWentWrong';
messageApi.error(`${fieldPath}: ${t(msgKey, { defaultValue: msgKey })}`);
return;
}
await saveAll();
}
function restartPanel() {
modal.confirm({
title: t('pages.settings.restartPanel'),
content: t('pages.settings.restartPanelDesc'),
okText: t('pages.settings.restartPanel'),
okButtonProps: { danger: true },
cancelText: t('cancel'),
onOk: async () => {
setSpinning(true);
try {
const msg = (await HttpUtil.post('/panel/api/setting/restartPanel')) as ApiMsg;
if (!msg?.success) return;
await PromiseUtil.sleep(5000);
window.location.replace(rebuildUrlAfterRestart());
} finally {
setSpinning(false);
}
},
});
}
const confAlerts = useMemo<string[]>(() => {
const out: string[] = [];
if (window.location.protocol !== 'https:') {
out.push(t('pages.settings.warnHttp'));
}
if (allSetting.webPort === 2053) {
out.push(t('pages.settings.warnDefaultPort'));
}
const segs = window.location.pathname.split('/').length < 4;
if (segs && allSetting.webBasePath === '/') {
out.push(t('pages.settings.warnDefaultBasePath'));
}
if (allSetting.subEnable) {
let subPath = allSetting.subPath;
if (allSetting.subURI) {
try {
subPath = new URL(allSetting.subURI).pathname;
} catch {
/* noop */
}
}
if (subPath === '/sub/') {
out.push(t('pages.settings.warnDefaultSubPath'));
}
}
if (allSetting.subJsonEnable) {
let p = allSetting.subJsonPath;
if (allSetting.subJsonURI) {
try {
p = new URL(allSetting.subJsonURI).pathname;
} catch {
/* noop */
}
}
if (p === '/json/') {
out.push(t('pages.settings.warnDefaultJsonPath'));
}
}
return out;
}, [allSetting, t]);
const pageClass = useMemo(() => {
const classes = ['settings-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const categoryBody = useMemo(() => {
switch (activeSlug) {
case 'security':
return (
<SecurityTab
allSetting={allSetting}
updateSetting={updateSetting}
saveSetting={savePayload}
/>
);
case 'telegram':
return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'email':
return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'discord':
return <DiscordTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription':
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-formats':
return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-balancers':
return <SubscriptionBalancersTab allSetting={allSetting} updateSetting={updateSetting} />;
default:
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
}
}, [activeSlug, allSetting, updateSetting, savePayload]);
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin
aria-live="off"
spinning={spinning || !fetched}
delay={200}
description={t('loading')}
size="large"
>
{!fetched ? (
<div className="loading-spacer" />
) : (
<>
{confAlerts.length > 0 && alertVisible && (
<Alert
type="error"
showIcon
closable={{ onClose: () => setAlertVisible(false) }}
className="conf-alert"
title={t('pages.settings.securityWarnings')}
description={
<>
<b>{t('pages.settings.panelExposed')}</b>
<ul>
{confAlerts.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</>
}
/>
)}
<Row gutter={[isMobile ? 8 : 16, isMobile ? 0 : 12]}>
<Col span={24}>
<Card hoverable>
<Row className="header-row">
<Col xs={24} sm={10} className="header-actions">
<Space>
<Button type="primary" disabled={saveDisabled} onClick={onSave}>
{t('pages.settings.save')}
</Button>
<Button
type="primary"
danger
disabled={!saveDisabled}
onClick={restartPanel}
>
{t('pages.settings.restartPanel')}
</Button>
</Space>
</Col>
<Col xs={24} sm={14} className="header-info">
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card hoverable>{categoryBody}</Card>
</Col>
</Row>
</>
)}
</Spin>
</Layout.Content>
</Layout>
</Layout>
</ConfigProvider>
);
}