mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 15:25: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.
512 lines
17 KiB
TypeScript
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 }))}
|
|
/>
|
|
</>
|
|
);
|
|
}
|