import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Empty, Form, Input, Modal, Space, Spin, Switch, Tabs, message, } from 'antd'; import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons'; import { ClipboardManager, HttpUtil, 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 { success?: boolean; msg?: string; obj?: T; } interface ApiTokenRow { id: number; name: string; enabled: boolean; createdAt: number; } interface SecurityTabProps { allSetting: AllSetting; updateSetting: (patch: Partial) => void; } 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 }: SecurityTabProps) { const { t } = useTranslation(); const { isMobile } = useMediaQuery(); const [modal, modalContextHolder] = Modal.useModal(); const [messageApi, messageContextHolder] = message.useMessage(); const [tfa, setTfa] = useState(TFA_INITIAL); const [user, setUser] = useState({ oldUsername: '', oldPassword: '', newUsername: '', newPassword: '', }); const [updating, setUpdating] = useState(false); const [apiTokens, setApiTokens] = useState([]); const [apiTokensLoading, setApiTokensLoading] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [createName, setCreateName] = useState(''); const [creating, setCreating] = useState(false); const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null); const openTfa = useCallback((opts: Omit) => { setTfa({ ...opts, open: true }); }, []); const onTfaConfirm = useCallback((success: boolean, code?: string) => { tfa.onConfirm(success, code); }, [tfa]); function updateUserField(key: K, value: string) { setUser((prev) => ({ ...prev, [key]: value })); } const sendUpdateUser = useCallback(async () => { setUpdating(true); try { const msg = await HttpUtil.post('/panel/setting/updateUser', user) 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: allSetting.twoFactorToken, type: 'confirm', onConfirm: (ok: boolean) => { if (ok) sendUpdateUser(); }, }); } else { sendUpdateUser(); } } const loadApiTokens = useCallback(async () => { setApiTokensLoading(true); try { const msg = await HttpUtil.get('/panel/setting/apiTokens') as ApiMsg; if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []); } finally { setApiTokensLoading(false); } }, []); useEffect(() => { loadApiTokens(); }, [loadApiTokens]); 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(''); 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/setting/apiTokens/create', { name }) 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/setting/apiTokens/delete/${row.id}`) as ApiMsg; if (msg?.success) await loadApiTokens(); }, }); } async function toggleTokenEnabled(row: ApiTokenRow) { const target = !row.enabled; const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) 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 new Date(ts * 1000).toLocaleString(); } 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: allSetting.twoFactorToken, type: 'confirm', onConfirm: (ok: boolean) => { if (!ok) return; messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess')); updateSetting({ twoFactorEnable: false, twoFactorToken: '' }); }, }); } } return ( <> {messageContextHolder} {modalContextHolder} , t('pages.settings.security.admin'), isMobile), children: ( <> updateUserField('oldUsername', e.target.value)} /> updateUserField('oldPassword', e.target.value)} /> updateUserField('newUsername', e.target.value)} /> updateUserField('newPassword', e.target.value)} />
), }, { key: '2', label: catTabLabel(, t('pages.settings.security.twoFactor'), isMobile), children: ( ), }, { key: '3', label: catTabLabel(, t('pages.nodes.apiToken'), isMobile), children: (

{t('pages.nodes.apiTokenHint')}

{!apiTokens.length && !apiTokensLoading && ( )} {apiTokens.map((row) => (
{row.name} {formatTokenDate(row.createdAt)}
toggleTokenEnabled(row)} />
))}
), }, ]} /> setCreateOpen(false)} >
setCreateName(e.target.value)} onPressEnter={confirmCreateToken} />
setCreatedToken(null)} onCancel={() => setCreatedToken(null)} cancelButtonProps={{ style: { display: 'none' } }} >

{t('pages.settings.security.apiTokenCreatedNotice') || 'Copy this token now. For security it is not stored in readable form and will not be shown again.'}

{createdToken?.token}
setTfa((prev) => ({ ...prev, open }))} /> ); }