Files
3x-ui/frontend/src/pages/settings/SecurityTab.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

512 lines
17 KiB
TypeScript

import { useCallback, useEffect, useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Empty,
Form,
Input,
Modal,
Select,
Space,
Spin,
Switch,
Tabs,
Tag,
message,
} from 'antd';
import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil, RandomUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import TwoFactorModal from './TwoFactorModal';
import './SecurityTab.css';
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
type ApiTokenScope = 'admin' | 'monitor' | 'node-sync' | 'node-admin';
interface ApiTokenRow {
id: number;
name: string;
enabled: boolean;
createdAt: number;
scope: ApiTokenScope;
expiresAt: number;
}
interface SecurityTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
saveSetting: (payload: Partial<AllSetting> & Record<string, unknown>) => Promise<unknown>;
}
const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000;
function apiTokenCreatedAtMilliseconds(createdAt: number): number {
return createdAt < UNIX_MILLISECONDS_THRESHOLD ? createdAt * 1000 : createdAt;
}
type TfaType = 'set' | 'confirm';
interface TfaState {
open: boolean;
title: string;
description: string;
token: string;
type: TfaType;
onConfirm: (success: boolean, code?: string) => void;
}
const TFA_INITIAL: TfaState = {
open: false,
title: '',
description: '',
token: '',
type: 'set',
onConfirm: () => {},
};
export default function SecurityTab({ allSetting, updateSetting, saveSetting }: SecurityTabProps) {
const { t } = useTranslation();
const uid = useId();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [tfa, setTfa] = useState<TfaState>(TFA_INITIAL);
const [user, setUser] = useState({
oldUsername: '',
oldPassword: '',
newUsername: '',
newPassword: '',
});
const [updating, setUpdating] = useState(false);
const [apiTokens, setApiTokens] = useState<ApiTokenRow[]>([]);
const [apiTokensLoading, setApiTokensLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState('');
const [createScope, setCreateScope] = useState<ApiTokenScope>('admin');
const [creating, setCreating] = useState(false);
const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null);
const openTfa = useCallback((opts: Omit<TfaState, 'open'>) => {
setTfa({ ...opts, open: true });
}, []);
const onTfaConfirm = useCallback(
(success: boolean, code?: string) => {
tfa.onConfirm(success, code);
},
[tfa],
);
function updateUserField<K extends keyof typeof user>(key: K, value: string) {
setUser((prev) => ({ ...prev, [key]: value }));
}
const sendUpdateUser = useCallback(
async (twoFactorCode = '') => {
setUpdating(true);
try {
const msg = (await HttpUtil.post('/panel/api/setting/updateUser', {
...user,
twoFactorCode,
})) as ApiMsg;
if (msg?.success) {
await HttpUtil.post('/logout');
const basePath = window.X_UI_BASE_PATH || '/';
window.location.replace(basePath);
}
} finally {
setUpdating(false);
}
},
[user],
);
function onUpdateUserClick() {
if (allSetting.twoFactorEnable) {
openTfa({
title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
token: '',
type: 'confirm',
onConfirm: (ok: boolean, code?: string) => {
if (ok) sendUpdateUser(code || '');
},
});
} else {
sendUpdateUser();
}
}
const fetchApiTokens = useCallback(async () => {
try {
const msg = (await HttpUtil.get('/panel/api/setting/apiTokens')) as ApiMsg<ApiTokenRow[]>;
if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
} finally {
setApiTokensLoading(false);
}
}, []);
const loadApiTokens = useCallback(async () => {
setApiTokensLoading(true);
await fetchApiTokens();
}, [fetchApiTokens]);
useEffect(() => {
void fetchApiTokens();
}, [fetchApiTokens]);
async function copyToken(token: string) {
if (!token) return;
const ok = await ClipboardManager.copyText(token);
if (ok) messageApi.success(t('copySuccess'));
else messageApi.error(t('copyFail') ?? 'Copy failed');
}
function openCreateModal() {
setCreateName('');
setCreateScope('admin');
setCreateOpen(true);
}
async function confirmCreateToken() {
const name = createName.trim();
if (!name) {
messageApi.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
return;
}
setCreating(true);
try {
const msg = (await HttpUtil.post('/panel/api/setting/apiTokens/create', {
name,
scope: createScope,
})) as ApiMsg<{ token?: string }>;
if (msg?.success) {
setCreateOpen(false);
await loadApiTokens();
if (msg.obj?.token) {
setCreatedToken({ name, token: msg.obj.token });
}
}
} finally {
setCreating(false);
}
}
function confirmDeleteToken(row: ApiTokenRow) {
modal.confirm({
title: `${t('delete')} "${row.name}"?`,
content:
t('pages.settings.security.apiTokenDeleteWarning') ||
'Any caller using this token will stop authenticating immediately.',
okText: t('delete'),
cancelText: t('cancel'),
okType: 'danger',
onOk: async () => {
const msg = (await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`, {
expectedScope: row.scope,
})) as ApiMsg;
if (msg?.success) await loadApiTokens();
},
});
}
async function toggleTokenEnabled(row: ApiTokenRow) {
const target = !row.enabled;
const msg = (await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, {
enabled: target,
expectedScope: row.scope,
})) as ApiMsg;
if (msg?.success) {
setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
}
}
function formatTokenDate(ts: number): string {
if (!ts) return '';
return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts));
}
const scopeMeta: Record<ApiTokenScope, { label: string; description: string; color: string }> = {
admin: {
label: t('pages.settings.security.apiTokenScopeAdmin'),
description: t('pages.settings.security.apiTokenScopeAdminDesc'),
color: 'red',
},
monitor: {
label: t('pages.settings.security.apiTokenScopeMonitor'),
description: t('pages.settings.security.apiTokenScopeMonitorDesc'),
color: 'blue',
},
'node-sync': {
label: t('pages.settings.security.apiTokenScopeNodeSync'),
description: t('pages.settings.security.apiTokenScopeNodeSyncDesc'),
color: 'gold',
},
'node-admin': {
label: t('pages.settings.security.apiTokenScopeNodeAdmin'),
description: t('pages.settings.security.apiTokenScopeNodeAdminDesc'),
color: 'purple',
},
};
function toggleTwoFactor() {
if (!allSetting.twoFactorEnable) {
const newToken = RandomUtil.randomBase32String();
openTfa({
title: t('pages.settings.security.twoFactorModalSetTitle'),
description: '',
token: newToken,
type: 'set',
onConfirm: (ok: boolean) => {
if (ok) {
messageApi.success(t('pages.settings.security.twoFactorModalSetSuccess'));
updateSetting({ twoFactorToken: newToken, twoFactorEnable: true });
} else {
updateSetting({ twoFactorEnable: false });
}
},
});
} else {
openTfa({
title: t('pages.settings.security.twoFactorModalDeleteTitle'),
description: t('pages.settings.security.twoFactorModalRemoveStep'),
token: '',
type: 'confirm',
onConfirm: async (ok: boolean, code?: string) => {
if (!ok) return;
const next = {
...allSetting,
twoFactorEnable: false,
twoFactorToken: '',
twoFactorCode: code || '',
};
const msg = (await saveSetting(next)) as ApiMsg;
if (msg?.success) {
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
updateSetting({ twoFactorEnable: false, twoFactorToken: '', hasTwoFactorToken: false });
}
},
});
}
}
return (
<>
{messageContextHolder}
{modalContextHolder}
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(
<UserOutlined aria-hidden />,
t('pages.settings.security.admin'),
isMobile,
),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.oldUsername')}>
<Input
value={user.oldUsername}
autoComplete="username"
onChange={(e) => updateUserField('oldUsername', e.target.value)}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.currentPassword')}>
<Input.Password
value={user.oldPassword}
autoComplete="current-password"
onChange={(e) => updateUserField('oldPassword', e.target.value)}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.newUsername')}>
<Input
value={user.newUsername}
onChange={(e) => updateUserField('newUsername', e.target.value)}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.newPassword')}>
<Input.Password
value={user.newPassword}
autoComplete="new-password"
onChange={(e) => updateUserField('newPassword', e.target.value)}
/>
</SettingListItem>
<div className="security-actions">
<Space style={{ padding: '0 20px' }}>
<Button type="primary" loading={updating} onClick={onUpdateUserClick}>
{t('confirm')}
</Button>
</Space>
</div>
</>
),
},
{
key: '2',
label: catTabLabel(
<SafetyOutlined aria-hidden />,
t('pages.settings.security.twoFactor'),
isMobile,
),
children: (
<SettingListItem
paddings="small"
title={t('pages.settings.security.twoFactorEnable')}
description={t('pages.settings.security.twoFactorEnableDesc')}
>
<Switch checked={allSetting.twoFactorEnable} onClick={toggleTwoFactor} />
</SettingListItem>
),
},
{
key: '3',
label: catTabLabel(<ApiOutlined aria-hidden />, t('pages.nodes.apiToken'), isMobile),
children: (
<div className="api-token-section">
<div className="api-token-header">
<p className="api-token-hint">{t('pages.nodes.apiTokenHint')}</p>
<Button type="primary" size="small" onClick={openCreateModal}>
+ {t('pages.settings.security.apiTokenNew') || 'New token'}
</Button>
</div>
<Spin spinning={apiTokensLoading}>
{!apiTokens.length && !apiTokensLoading && (
<Empty
description={t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'}
/>
)}
{apiTokens.map((row) => (
<div key={row.id} className={`api-token-row${row.enabled ? '' : ' disabled'}`}>
<div className="api-token-row-head">
<div className="api-token-name-wrap">
<div className="api-token-name-line">
<span id={`${uid}-token-${row.id}`} className="api-token-name">
{row.name}
</span>
<Tag color={scopeMeta[row.scope].color}>
{scopeMeta[row.scope].label}
</Tag>
</div>
<span className="api-token-created">
{formatTokenDate(row.createdAt)}
</span>
</div>
<div className="api-token-actions">
<Switch
size="small"
aria-labelledby={`${uid}-token-${row.id}`}
checked={row.enabled}
onChange={() => toggleTokenEnabled(row)}
/>
<Button
size="small"
danger
type="text"
onClick={() => confirmDeleteToken(row)}
>
{t('delete')}
</Button>
</div>
</div>
</div>
))}
</Spin>
</div>
),
},
]}
/>
<Modal
open={createOpen}
title={t('pages.settings.security.apiTokenNew') || 'New API token'}
confirmLoading={creating}
okText={t('confirm')}
cancelText={t('cancel')}
onOk={confirmCreateToken}
onCancel={() => setCreateOpen(false)}
>
<Form layout="vertical">
<Form.Item
label={t('pages.settings.security.apiTokenName') || 'Name'}
htmlFor={`${uid}-tokenName`}
required
>
<Input
id={`${uid}-tokenName`}
value={createName}
maxLength={64}
placeholder={
t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'
}
onChange={(e) => setCreateName(e.target.value)}
onPressEnter={confirmCreateToken}
/>
</Form.Item>
<Form.Item
label={t('pages.settings.security.apiTokenScope')}
extra={scopeMeta[createScope].description}
required
>
<Select
aria-label={t('pages.settings.security.apiTokenScope')}
value={createScope}
options={(Object.keys(scopeMeta) as ApiTokenScope[]).map((scope) => ({
value: scope,
label: scopeMeta[scope].label,
}))}
onChange={setCreateScope}
/>
</Form.Item>
</Form>
</Modal>
<Modal
open={!!createdToken}
title={t('pages.settings.security.apiTokenCreatedTitle') || 'Token created'}
okText={t('done')}
onOk={() => setCreatedToken(null)}
onCancel={() => setCreatedToken(null)}
cancelButtonProps={{ style: { display: 'none' } }}
>
<p className="api-token-created-notice">
{t('pages.settings.security.apiTokenCreatedNotice') ||
'Copy this token now. For security it is not stored in readable form and will not be shown again.'}
</p>
<div className="api-token-value-wrap">
<code className="api-token-value">{createdToken?.token}</code>
<Button
size="small"
type="primary"
onClick={() => createdToken && copyToken(createdToken.token)}
>
{t('copy')}
</Button>
</div>
</Modal>
<TwoFactorModal
open={tfa.open}
title={tfa.title}
description={tfa.description}
token={tfa.token}
type={tfa.type}
onConfirm={onTfaConfirm}
onOpenChange={(open) => setTfa((prev) => ({ ...prev, open }))}
/>
</>
);
}