Merge branch 'alpha' into feat-channel-block-edit
This commit is contained in:
@@ -32,6 +32,9 @@ import {
|
||||
onGitHubOAuthClicked,
|
||||
onOIDCClicked,
|
||||
onLinuxDOOAuthClicked,
|
||||
prepareCredentialRequestOptions,
|
||||
buildAssertionResult,
|
||||
isPasskeySupported,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
@@ -39,7 +42,7 @@ import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
|
||||
import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
|
||||
import { IconGithubLogo, IconMail, IconLock, IconKey } from '@douyinfe/semi-icons';
|
||||
import OIDCIcon from '../common/logo/OIDCIcon';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
||||
@@ -74,6 +77,8 @@ const LoginForm = () => {
|
||||
useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [showTwoFA, setShowTwoFA] = useState(false);
|
||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -95,6 +100,12 @@ const LoginForm = () => {
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
isPasskeySupported()
|
||||
.then(setPasskeySupported)
|
||||
.catch(() => setPasskeySupported(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('expired')) {
|
||||
showError(t('未登录或登录已过期,请重新登录'));
|
||||
@@ -266,6 +277,55 @@ const LoginForm = () => {
|
||||
setEmailLoginLoading(false);
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
if (!passkeySupported) {
|
||||
showInfo('当前环境无法使用 Passkey 登录');
|
||||
return;
|
||||
}
|
||||
if (!window.PublicKeyCredential) {
|
||||
showInfo('当前浏览器不支持 Passkey');
|
||||
return;
|
||||
}
|
||||
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
const beginRes = await API.post('/api/user/passkey/login/begin');
|
||||
const { success, message, data } = beginRes.data;
|
||||
if (!success) {
|
||||
showError(message || '无法发起 Passkey 登录');
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKeyOptions = prepareCredentialRequestOptions(data?.options || data?.publicKey || data);
|
||||
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions });
|
||||
const payload = buildAssertionResult(assertion);
|
||||
if (!payload) {
|
||||
showError('Passkey 验证失败,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
const finishRes = await API.post('/api/user/passkey/login/finish', payload);
|
||||
const finish = finishRes.data;
|
||||
if (finish.success) {
|
||||
userDispatch({ type: 'login', payload: finish.data });
|
||||
setUserData(finish.data);
|
||||
updateAPI();
|
||||
showSuccess('登录成功!');
|
||||
navigate('/console');
|
||||
} else {
|
||||
showError(finish.message || 'Passkey 登录失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
showInfo('已取消 Passkey 登录');
|
||||
} else {
|
||||
showError('Passkey 登录失败,请重试');
|
||||
}
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的重置密码点击处理
|
||||
const handleResetPasswordClick = () => {
|
||||
setResetPasswordLoading(true);
|
||||
@@ -385,6 +445,19 @@ const LoginForm = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.passkey_login && passkeySupported && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconKey size='large' />}
|
||||
onClick={handlePasskeyLogin}
|
||||
loading={passkeyLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
@@ -437,6 +510,18 @@ const LoginForm = () => {
|
||||
</Title>
|
||||
</div>
|
||||
<div className='px-2 py-8'>
|
||||
{status.passkey_login && passkeySupported && (
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
|
||||
icon={<IconKey size='large' />}
|
||||
onClick={handlePasskeyLogin}
|
||||
loading={passkeyLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field='username'
|
||||
|
||||
117
web/src/components/common/examples/ChannelKeyViewExample.jsx
Normal file
117
web/src/components/common/examples/ChannelKeyViewExample.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Modal } from '@douyinfe/semi-ui';
|
||||
import { useSecureVerification } from '../../../hooks/common/useSecureVerification';
|
||||
import { createApiCalls } from '../../../services/secureVerification';
|
||||
import SecureVerificationModal from '../modals/SecureVerificationModal';
|
||||
import ChannelKeyDisplay from '../ui/ChannelKeyDisplay';
|
||||
|
||||
/**
|
||||
* 渠道密钥查看组件使用示例
|
||||
* 展示如何使用通用安全验证系统
|
||||
*/
|
||||
const ChannelKeyViewExample = ({ channelId }) => {
|
||||
const { t } = useTranslation();
|
||||
const [keyData, setKeyData] = useState('');
|
||||
const [showKeyModal, setShowKeyModal] = useState(false);
|
||||
|
||||
// 使用通用安全验证 Hook
|
||||
const {
|
||||
isModalVisible,
|
||||
verificationMethods,
|
||||
verificationState,
|
||||
startVerification,
|
||||
executeVerification,
|
||||
cancelVerification,
|
||||
setVerificationCode,
|
||||
switchVerificationMethod,
|
||||
} = useSecureVerification({
|
||||
onSuccess: (result) => {
|
||||
// 验证成功后处理结果
|
||||
if (result.success && result.data?.key) {
|
||||
setKeyData(result.data.key);
|
||||
setShowKeyModal(true);
|
||||
}
|
||||
},
|
||||
successMessage: t('密钥获取成功'),
|
||||
});
|
||||
|
||||
// 开始查看密钥流程
|
||||
const handleViewKey = async () => {
|
||||
const apiCall = createApiCalls.viewChannelKey(channelId);
|
||||
|
||||
await startVerification(apiCall, {
|
||||
title: t('查看渠道密钥'),
|
||||
description: t('为了保护账户安全,请验证您的身份。'),
|
||||
preferredMethod: 'passkey', // 可以指定首选验证方式
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 查看密钥按钮 */}
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleViewKey}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
|
||||
{/* 安全验证模态框 */}
|
||||
<SecureVerificationModal
|
||||
visible={isModalVisible}
|
||||
verificationMethods={verificationMethods}
|
||||
verificationState={verificationState}
|
||||
onVerify={executeVerification}
|
||||
onCancel={cancelVerification}
|
||||
onCodeChange={setVerificationCode}
|
||||
onMethodSwitch={switchVerificationMethod}
|
||||
title={verificationState.title}
|
||||
description={verificationState.description}
|
||||
/>
|
||||
|
||||
{/* 密钥显示模态框 */}
|
||||
<Modal
|
||||
title={t('渠道密钥信息')}
|
||||
visible={showKeyModal}
|
||||
onCancel={() => setShowKeyModal(false)}
|
||||
footer={
|
||||
<Button type='primary' onClick={() => setShowKeyModal(false)}>
|
||||
{t('完成')}
|
||||
</Button>
|
||||
}
|
||||
width={700}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<ChannelKeyDisplay
|
||||
keyData={keyData}
|
||||
showSuccessIcon={true}
|
||||
successText={t('密钥获取成功')}
|
||||
showWarning={true}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelKeyViewExample;
|
||||
285
web/src/components/common/modals/SecureVerificationModal.jsx
Normal file
285
web/src/components/common/modals/SecureVerificationModal.jsx
Normal file
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } from '@douyinfe/semi-ui';
|
||||
|
||||
/**
|
||||
* 通用安全验证模态框组件
|
||||
* 配合 useSecureVerification Hook 使用
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.visible - 是否显示模态框
|
||||
* @param {Object} props.verificationMethods - 可用的验证方式
|
||||
* @param {Object} props.verificationState - 当前验证状态
|
||||
* @param {Function} props.onVerify - 验证回调
|
||||
* @param {Function} props.onCancel - 取消回调
|
||||
* @param {Function} props.onCodeChange - 验证码变化回调
|
||||
* @param {Function} props.onMethodSwitch - 验证方式切换回调
|
||||
* @param {string} props.title - 模态框标题
|
||||
* @param {string} props.description - 验证描述文本
|
||||
*/
|
||||
const SecureVerificationModal = ({
|
||||
visible,
|
||||
verificationMethods,
|
||||
verificationState,
|
||||
onVerify,
|
||||
onCancel,
|
||||
onCodeChange,
|
||||
onMethodSwitch,
|
||||
title,
|
||||
description,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [verifySuccess, setVerifySuccess] = useState(false);
|
||||
|
||||
const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
|
||||
const { method, loading, code } = verificationState;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setIsAnimating(true);
|
||||
setVerifySuccess(false);
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
|
||||
onVerify(method, code);
|
||||
}
|
||||
if (e.key === 'Escape' && !loading) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
// 如果用户没有启用任何验证方式
|
||||
if (visible && !has2FA && !hasPasskey) {
|
||||
return (
|
||||
<Modal
|
||||
title={title || t('安全验证')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<Button onClick={onCancel}>{t('确定')}</Button>
|
||||
}
|
||||
width={500}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<div className='text-center py-6'>
|
||||
<div className='mb-4'>
|
||||
<svg
|
||||
className='w-16 h-16 text-yellow-500 mx-auto mb-4'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<Typography.Title heading={4} className='mb-2'>
|
||||
{t('需要安全验证')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')}
|
||||
</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('请前往个人设置 → 安全设置进行配置。')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title || t('安全验证')}
|
||||
visible={visible}
|
||||
onCancel={loading ? undefined : onCancel}
|
||||
closeOnEsc={!loading}
|
||||
footer={null}
|
||||
width={460}
|
||||
centered
|
||||
style={{
|
||||
maxWidth: 'calc(100vw - 32px)'
|
||||
}}
|
||||
bodyStyle={{
|
||||
padding: '20px 24px'
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
{/* 描述信息 */}
|
||||
{description && (
|
||||
<Typography.Paragraph
|
||||
type="tertiary"
|
||||
style={{
|
||||
margin: '0 0 20px 0',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6'
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
{/* 验证方式选择 */}
|
||||
<Tabs
|
||||
activeKey={method}
|
||||
onChange={onMethodSwitch}
|
||||
type='line'
|
||||
size='default'
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{has2FA && (
|
||||
<TabPane
|
||||
tab={t('两步验证')}
|
||||
itemKey='2fa'
|
||||
>
|
||||
<div style={{ paddingTop: '20px' }}>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Input
|
||||
placeholder={t('请输入6位验证码或8位备用码')}
|
||||
value={code}
|
||||
onChange={onCodeChange}
|
||||
size='large'
|
||||
maxLength={8}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={method === '2fa'}
|
||||
disabled={loading}
|
||||
prefix={
|
||||
<svg style={{ width: 16, height: 16, marginRight: 8, flexShrink: 0 }} fill='currentColor' viewBox='0 0 20 20'>
|
||||
<path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
|
||||
</svg>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Typography.Text
|
||||
type="tertiary"
|
||||
size="small"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '20px',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
>
|
||||
{t('从认证器应用中获取验证码,或使用备用码')}
|
||||
</Typography.Text>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
disabled={!code.trim() || loading}
|
||||
onClick={() => onVerify(method, code)}
|
||||
>
|
||||
{t('验证')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
)}
|
||||
|
||||
{hasPasskey && passkeySupported && (
|
||||
<TabPane
|
||||
tab={t('Passkey')}
|
||||
itemKey='passkey'
|
||||
>
|
||||
<div style={{ paddingTop: '20px' }}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 16px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
margin: '0 auto 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--semi-color-primary-light-default)',
|
||||
}}>
|
||||
<svg style={{ width: 28, height: 28, color: 'var(--semi-color-primary)' }} fill='currentColor' viewBox='0 0 20 20'>
|
||||
<path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
|
||||
</svg>
|
||||
</div>
|
||||
<Typography.Title heading={5} style={{ margin: '0 0 8px', fontSize: '16px' }}>
|
||||
{t('使用 Passkey 验证')}
|
||||
</Typography.Title>
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
style={{
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
>
|
||||
{t('点击验证按钮,使用您的生物特征或安全密钥')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={() => onVerify(method)}
|
||||
>
|
||||
{t('验证 Passkey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecureVerificationModal;
|
||||
@@ -58,7 +58,7 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
loading: sidebarLoading,
|
||||
} = useSidebar();
|
||||
|
||||
const showSkeleton = useMinimumLoadingTime(sidebarLoading);
|
||||
const showSkeleton = useMinimumLoadingTime(sidebarLoading, 200);
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState(['home']);
|
||||
const [chatItems, setChatItems] = useState([]);
|
||||
|
||||
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import { Button, Dropdown } from '@douyinfe/semi-ui';
|
||||
import { Languages } from 'lucide-react';
|
||||
import { CN, GB } from 'country-flag-icons/react/3x2';
|
||||
import { CN, GB, FR } from 'country-flag-icons/react/3x2';
|
||||
|
||||
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
|
||||
return (
|
||||
@@ -42,12 +42,19 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
|
||||
<GB title='English' className='!w-5 !h-auto' />
|
||||
<span>English</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => onLanguageChange('fr')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'fr' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<FR title='Français' className='!w-5 !h-auto' />
|
||||
<span>Français</span>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={<Languages size={18} />}
|
||||
aria-label={t('切换语言')}
|
||||
aria-label={t('common.changeLanguage')}
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
||||
|
||||
@@ -45,6 +45,7 @@ const PaymentSetting = () => {
|
||||
StripePriceId: '',
|
||||
StripeUnitPrice: 8.0,
|
||||
StripeMinTopUp: 1,
|
||||
StripePromotionCodesEnabled: false,
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -19,7 +19,18 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
setStatusData,
|
||||
prepareCredentialCreationOptions,
|
||||
buildRegistrationResult,
|
||||
isPasskeySupported,
|
||||
setUserData,
|
||||
} from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -59,6 +70,10 @@ const PersonalSetting = () => {
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [systemToken, setSystemToken] = useState('');
|
||||
const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false });
|
||||
const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
|
||||
const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
|
||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||
const [notificationSettings, setNotificationSettings] = useState({
|
||||
warningType: 'email',
|
||||
warningThreshold: 100000,
|
||||
@@ -66,23 +81,52 @@ const PersonalSetting = () => {
|
||||
webhookSecret: '',
|
||||
notificationEmail: '',
|
||||
barkUrl: '',
|
||||
gotifyUrl: '',
|
||||
gotifyToken: '',
|
||||
gotifyPriority: 5,
|
||||
acceptUnsetModelRatioModel: false,
|
||||
recordIpLog: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
if (status.turnstile_check) {
|
||||
let saved = localStorage.getItem('status');
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
setStatus(parsed);
|
||||
if (parsed.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
setTurnstileSiteKey(parsed.turnstile_site_key);
|
||||
} else {
|
||||
setTurnstileEnabled(false);
|
||||
setTurnstileSiteKey('');
|
||||
}
|
||||
}
|
||||
getUserData().then((res) => {
|
||||
console.log(userState);
|
||||
});
|
||||
// Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth)
|
||||
(async () => {
|
||||
try {
|
||||
const res = await API.get('/api/status');
|
||||
const { success, data } = res.data;
|
||||
if (success && data) {
|
||||
setStatus(data);
|
||||
setStatusData(data);
|
||||
if (data.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(data.turnstile_site_key);
|
||||
} else {
|
||||
setTurnstileEnabled(false);
|
||||
setTurnstileSiteKey('');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore and keep local status
|
||||
}
|
||||
})();
|
||||
|
||||
getUserData();
|
||||
|
||||
isPasskeySupported()
|
||||
.then(setPasskeySupported)
|
||||
.catch(() => setPasskeySupported(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -108,6 +152,12 @@ const PersonalSetting = () => {
|
||||
webhookSecret: settings.webhook_secret || '',
|
||||
notificationEmail: settings.notification_email || '',
|
||||
barkUrl: settings.bark_url || '',
|
||||
gotifyUrl: settings.gotify_url || '',
|
||||
gotifyToken: settings.gotify_token || '',
|
||||
gotifyPriority:
|
||||
settings.gotify_priority !== undefined
|
||||
? settings.gotify_priority
|
||||
: 5,
|
||||
acceptUnsetModelRatioModel:
|
||||
settings.accept_unset_model_ratio_model || false,
|
||||
recordIpLog: settings.record_ip_log || false,
|
||||
@@ -131,11 +181,90 @@ const PersonalSetting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPasskeyStatus = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/user/passkey');
|
||||
const { success, data, message } = res.data;
|
||||
if (success) {
|
||||
setPasskeyStatus({
|
||||
enabled: data?.enabled || false,
|
||||
last_used_at: data?.last_used_at || null,
|
||||
backup_eligible: data?.backup_eligible || false,
|
||||
backup_state: data?.backup_state || false,
|
||||
});
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略错误,保留默认状态
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterPasskey = async () => {
|
||||
if (!passkeySupported || !window.PublicKeyCredential) {
|
||||
showInfo(t('当前设备不支持 Passkey'));
|
||||
return;
|
||||
}
|
||||
setPasskeyRegisterLoading(true);
|
||||
try {
|
||||
const beginRes = await API.post('/api/user/passkey/register/begin');
|
||||
const { success, message, data } = beginRes.data;
|
||||
if (!success) {
|
||||
showError(message || t('无法发起 Passkey 注册'));
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data);
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
const payload = buildRegistrationResult(credential);
|
||||
if (!payload) {
|
||||
showError(t('Passkey 注册失败,请重试'));
|
||||
return;
|
||||
}
|
||||
|
||||
const finishRes = await API.post('/api/user/passkey/register/finish', payload);
|
||||
if (finishRes.data.success) {
|
||||
showSuccess(t('Passkey 注册成功'));
|
||||
await loadPasskeyStatus();
|
||||
} else {
|
||||
showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
showInfo(t('已取消 Passkey 注册'));
|
||||
} else {
|
||||
showError(t('Passkey 注册失败,请重试'));
|
||||
}
|
||||
} finally {
|
||||
setPasskeyRegisterLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePasskey = async () => {
|
||||
setPasskeyDeleteLoading(true);
|
||||
try {
|
||||
const res = await API.delete('/api/user/passkey');
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('Passkey 已解绑'));
|
||||
await loadPasskeyStatus();
|
||||
} else {
|
||||
showError(message || t('操作失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败,请重试'));
|
||||
} finally {
|
||||
setPasskeyDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserData = async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
setUserData(data);
|
||||
await loadPasskeyStatus();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -286,6 +415,12 @@ const PersonalSetting = () => {
|
||||
webhook_secret: notificationSettings.webhookSecret,
|
||||
notification_email: notificationSettings.notificationEmail,
|
||||
bark_url: notificationSettings.barkUrl,
|
||||
gotify_url: notificationSettings.gotifyUrl,
|
||||
gotify_token: notificationSettings.gotifyToken,
|
||||
gotify_priority: (() => {
|
||||
const parsed = parseInt(notificationSettings.gotifyPriority);
|
||||
return isNaN(parsed) ? 5 : parsed;
|
||||
})(),
|
||||
accept_unset_model_ratio_model:
|
||||
notificationSettings.acceptUnsetModelRatioModel,
|
||||
record_ip_log: notificationSettings.recordIpLog,
|
||||
@@ -323,6 +458,12 @@ const PersonalSetting = () => {
|
||||
handleSystemTokenClick={handleSystemTokenClick}
|
||||
setShowChangePasswordModal={setShowChangePasswordModal}
|
||||
setShowAccountDeleteModal={setShowAccountDeleteModal}
|
||||
passkeyStatus={passkeyStatus}
|
||||
passkeySupported={passkeySupported}
|
||||
passkeyRegisterLoading={passkeyRegisterLoading}
|
||||
passkeyDeleteLoading={passkeyDeleteLoading}
|
||||
onPasskeyRegister={handleRegisterPasskey}
|
||||
onPasskeyDelete={handleRemovePasskey}
|
||||
/>
|
||||
|
||||
{/* 右侧:其他设置 */}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Spin,
|
||||
Card,
|
||||
Radio,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
const { Text } = Typography;
|
||||
import {
|
||||
@@ -77,6 +78,13 @@ const SystemSetting = () => {
|
||||
TurnstileSiteKey: '',
|
||||
TurnstileSecretKey: '',
|
||||
RegisterEnabled: '',
|
||||
'passkey.enabled': '',
|
||||
'passkey.rp_display_name': '',
|
||||
'passkey.rp_id': '',
|
||||
'passkey.origins': [],
|
||||
'passkey.allow_insecure_origin': '',
|
||||
'passkey.user_verification': 'preferred',
|
||||
'passkey.attachment_preference': '',
|
||||
EmailDomainRestrictionEnabled: '',
|
||||
EmailAliasRestrictionEnabled: '',
|
||||
SMTPSSLEnabled: '',
|
||||
@@ -173,9 +181,25 @@ const SystemSetting = () => {
|
||||
case 'SMTPSSLEnabled':
|
||||
case 'LinuxDOOAuthEnabled':
|
||||
case 'oidc.enabled':
|
||||
case 'passkey.enabled':
|
||||
case 'passkey.allow_insecure_origin':
|
||||
case 'WorkerAllowHttpImageRequestEnabled':
|
||||
item.value = toBoolean(item.value);
|
||||
break;
|
||||
case 'passkey.origins':
|
||||
// origins是逗号分隔的字符串,直接使用
|
||||
item.value = item.value || '';
|
||||
break;
|
||||
case 'passkey.rp_display_name':
|
||||
case 'passkey.rp_id':
|
||||
case 'passkey.attachment_preference':
|
||||
// 确保字符串字段不为null/undefined
|
||||
item.value = item.value || '';
|
||||
break;
|
||||
case 'passkey.user_verification':
|
||||
// 确保有默认值
|
||||
item.value = item.value || 'preferred';
|
||||
break;
|
||||
case 'Price':
|
||||
case 'MinTopUp':
|
||||
item.value = parseFloat(item.value);
|
||||
@@ -582,6 +606,36 @@ const SystemSetting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const submitPasskeySettings = async () => {
|
||||
// 使用formApi直接获取当前表单值
|
||||
const formValues = formApiRef.current?.getValues() || {};
|
||||
|
||||
const options = [];
|
||||
|
||||
options.push({
|
||||
key: 'passkey.rp_display_name',
|
||||
value: formValues['passkey.rp_display_name'] || inputs['passkey.rp_display_name'] || '',
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.rp_id',
|
||||
value: formValues['passkey.rp_id'] || inputs['passkey.rp_id'] || '',
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.user_verification',
|
||||
value: formValues['passkey.user_verification'] || inputs['passkey.user_verification'] || 'preferred',
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.attachment_preference',
|
||||
value: formValues['passkey.attachment_preference'] || inputs['passkey.attachment_preference'] || '',
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.origins',
|
||||
value: formValues['passkey.origins'] || inputs['passkey.origins'] || '',
|
||||
});
|
||||
|
||||
await updateOptions(options);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = async (optionKey, event) => {
|
||||
const value = event.target.checked;
|
||||
|
||||
@@ -957,6 +1011,116 @@ const SystemSetting = () => {
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('配置 Passkey')}>
|
||||
<Text>{t('用以支持基于 WebAuthn 的无密码登录注册')}</Text>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t('Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式')}
|
||||
style={{ marginBottom: 20, marginTop: 16 }}
|
||||
/>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Form.Checkbox
|
||||
field="['passkey.enabled']"
|
||||
noLabel
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('passkey.enabled', e)
|
||||
}
|
||||
>
|
||||
{t('允许通过 Passkey 登录 & 认证')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field="['passkey.rp_display_name']"
|
||||
label={t('服务显示名称')}
|
||||
placeholder={t('默认使用系统名称')}
|
||||
extraText={t('用户注册时看到的网站名称,比如\'我的网站\'')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field="['passkey.rp_id']"
|
||||
label={t('网站域名标识')}
|
||||
placeholder={t('例如:example.com')}
|
||||
extraText={t('留空则默认使用服务器地址,注意不能携带http://或者https://')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Select
|
||||
field="['passkey.user_verification']"
|
||||
label={t('安全验证级别')}
|
||||
placeholder={t('是否要求指纹/面容等生物识别')}
|
||||
optionList={[
|
||||
{ label: t('推荐使用(用户可选)'), value: 'preferred' },
|
||||
{ label: t('强制要求'), value: 'required' },
|
||||
{ label: t('不建议使用'), value: 'discouraged' },
|
||||
]}
|
||||
extraText={t('推荐:用户可以选择是否使用指纹等验证')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Select
|
||||
field="['passkey.attachment_preference']"
|
||||
label={t('设备类型偏好')}
|
||||
placeholder={t('选择支持的认证设备类型')}
|
||||
optionList={[
|
||||
{ label: t('不限制'), value: '' },
|
||||
{ label: t('本设备内置'), value: 'platform' },
|
||||
{ label: t('外接设备'), value: 'cross-platform' },
|
||||
]}
|
||||
extraText={t('本设备:手机指纹/面容,外接:USB安全密钥')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Form.Checkbox
|
||||
field="['passkey.allow_insecure_origin']"
|
||||
noLabel
|
||||
extraText={t('仅用于开发环境,生产环境应使用 HTTPS')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('passkey.allow_insecure_origin', e)
|
||||
}
|
||||
>
|
||||
{t('允许不安全的 Origin(HTTP)')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Form.Input
|
||||
field="['passkey.origins']"
|
||||
label={t('允许的 Origins')}
|
||||
placeholder={t('填写带https的域名,逗号分隔')}
|
||||
extraText={t('为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[],需使用https')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitPasskeySettings} style={{ marginTop: 16 }}>
|
||||
{t('保存 Passkey 设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('配置邮箱域名白名单')}>
|
||||
<Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Tabs,
|
||||
TabPane,
|
||||
Popover,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconMail,
|
||||
@@ -58,6 +59,12 @@ const AccountManagement = ({
|
||||
handleSystemTokenClick,
|
||||
setShowChangePasswordModal,
|
||||
setShowAccountDeleteModal,
|
||||
passkeyStatus,
|
||||
passkeySupported,
|
||||
passkeyRegisterLoading,
|
||||
passkeyDeleteLoading,
|
||||
onPasskeyRegister,
|
||||
onPasskeyDelete,
|
||||
}) => {
|
||||
const renderAccountInfo = (accountId, label) => {
|
||||
if (!accountId || accountId === '') {
|
||||
@@ -83,6 +90,13 @@ const AccountManagement = ({
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
const isBound = (accountId) => Boolean(accountId);
|
||||
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
|
||||
const passkeyEnabled = passkeyStatus?.enabled;
|
||||
const lastUsedLabel = passkeyStatus?.last_used_at
|
||||
? new Date(passkeyStatus.last_used_at).toLocaleString()
|
||||
: t('尚未使用');
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl'>
|
||||
{/* 卡片头部 */}
|
||||
@@ -142,7 +156,7 @@ const AccountManagement = ({
|
||||
size='small'
|
||||
onClick={() => setShowEmailBindModal(true)}
|
||||
>
|
||||
{userState.user && userState.user.email !== ''
|
||||
{isBound(userState.user?.email)
|
||||
? t('修改绑定')
|
||||
: t('绑定')}
|
||||
</Button>
|
||||
@@ -165,9 +179,11 @@ const AccountManagement = ({
|
||||
{t('微信')}
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{userState.user && userState.user.wechat_id !== ''
|
||||
? t('已绑定')
|
||||
: t('未绑定')}
|
||||
{!status.wechat_login
|
||||
? t('未启用')
|
||||
: isBound(userState.user?.wechat_id)
|
||||
? t('已绑定')
|
||||
: t('未绑定')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,7 +195,7 @@ const AccountManagement = ({
|
||||
disabled={!status.wechat_login}
|
||||
onClick={() => setShowWeChatBindModal(true)}
|
||||
>
|
||||
{userState.user && userState.user.wechat_id !== ''
|
||||
{isBound(userState.user?.wechat_id)
|
||||
? t('修改绑定')
|
||||
: status.wechat_login
|
||||
? t('绑定')
|
||||
@@ -220,8 +236,7 @@ const AccountManagement = ({
|
||||
onGitHubOAuthClicked(status.github_client_id)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.github_id !== '') ||
|
||||
!status.github_oauth
|
||||
isBound(userState.user?.github_id) || !status.github_oauth
|
||||
}
|
||||
>
|
||||
{status.github_oauth ? t('绑定') : t('未启用')}
|
||||
@@ -264,8 +279,7 @@ const AccountManagement = ({
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.oidc_id !== '') ||
|
||||
!status.oidc_enabled
|
||||
isBound(userState.user?.oidc_id) || !status.oidc_enabled
|
||||
}
|
||||
>
|
||||
{status.oidc_enabled ? t('绑定') : t('未启用')}
|
||||
@@ -298,26 +312,56 @@ const AccountManagement = ({
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
{status.telegram_oauth ? (
|
||||
userState.user.telegram_id !== '' ? (
|
||||
<Button disabled={true} size='small'>
|
||||
isBound(userState.user?.telegram_id) ? (
|
||||
<Button
|
||||
disabled
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
>
|
||||
{t('已绑定')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className='scale-75'>
|
||||
<TelegramLoginButton
|
||||
dataAuthUrl='/api/oauth/telegram/bind'
|
||||
botName={status.telegram_bot_name}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
onClick={() => setShowTelegramBindModal(true)}
|
||||
>
|
||||
{t('绑定')}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button disabled={true} size='small'>
|
||||
<Button
|
||||
disabled
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
>
|
||||
{t('未启用')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Modal
|
||||
title={t('绑定 Telegram')}
|
||||
visible={showTelegramBindModal}
|
||||
onCancel={() => setShowTelegramBindModal(false)}
|
||||
footer={null}
|
||||
>
|
||||
<div className='my-3 text-sm text-gray-600'>
|
||||
{t('点击下方按钮通过 Telegram 完成绑定')}
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
<div className='scale-90'>
|
||||
<TelegramLoginButton
|
||||
dataAuthUrl='/api/oauth/telegram/bind'
|
||||
botName={status.telegram_bot_name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* LinuxDO绑定 */}
|
||||
<Card className='!rounded-xl'>
|
||||
@@ -350,8 +394,7 @@ const AccountManagement = ({
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.linux_do_id !== '') ||
|
||||
!status.linuxdo_oauth
|
||||
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
|
||||
}
|
||||
>
|
||||
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
||||
@@ -443,6 +486,71 @@ const AccountManagement = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Passkey 设置 */}
|
||||
<Card className='!rounded-xl w-full'>
|
||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
||||
<div className='flex items-start w-full sm:w-auto'>
|
||||
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
||||
<IconKey size='large' className='text-slate-600' />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={6} className='mb-1'>
|
||||
{t('Passkey 登录')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{passkeyEnabled
|
||||
? t('已启用 Passkey,无需密码即可登录')
|
||||
: t('使用 Passkey 实现免密且更安全的登录体验')}
|
||||
</Typography.Text>
|
||||
<div className='mt-2 text-xs text-gray-500 space-y-1'>
|
||||
<div>
|
||||
{t('最后使用时间')}:{lastUsedLabel}
|
||||
</div>
|
||||
{/*{passkeyEnabled && (*/}
|
||||
{/* <div>*/}
|
||||
{/* {t('备份支持')}:*/}
|
||||
{/* {passkeyStatus?.backup_eligible*/}
|
||||
{/* ? t('支持备份')*/}
|
||||
{/* : t('不支持')}*/}
|
||||
{/* ,{t('备份状态')}:*/}
|
||||
{/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
{!passkeySupported && (
|
||||
<div className='text-amber-600'>
|
||||
{t('当前设备不支持 Passkey')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type={passkeyEnabled ? 'danger' : 'primary'}
|
||||
theme={passkeyEnabled ? 'solid' : 'solid'}
|
||||
onClick={
|
||||
passkeyEnabled
|
||||
? () => {
|
||||
Modal.confirm({
|
||||
title: t('确认解绑 Passkey'),
|
||||
content: t('解绑后将无法使用 Passkey 登录,确定要继续吗?'),
|
||||
okText: t('确认解绑'),
|
||||
cancelText: t('取消'),
|
||||
okType: 'danger',
|
||||
onOk: onPasskeyDelete,
|
||||
});
|
||||
}
|
||||
: onPasskeyRegister
|
||||
}
|
||||
className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
|
||||
icon={<IconKey />}
|
||||
disabled={!passkeySupported && !passkeyEnabled}
|
||||
loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
|
||||
>
|
||||
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 两步验证设置 */}
|
||||
<TwoFASetting t={t} />
|
||||
|
||||
|
||||
@@ -400,6 +400,7 @@ const NotificationSettings = ({
|
||||
<Radio value='email'>{t('邮件通知')}</Radio>
|
||||
<Radio value='webhook'>{t('Webhook通知')}</Radio>
|
||||
<Radio value='bark'>{t('Bark通知')}</Radio>
|
||||
<Radio value='gotify'>{t('Gotify通知')}</Radio>
|
||||
</Form.RadioGroup>
|
||||
|
||||
<Form.AutoComplete
|
||||
@@ -589,7 +590,108 @@ const NotificationSettings = ({
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 hover:text-blue-600 font-medium'
|
||||
>
|
||||
Bark 官方文档
|
||||
Bark {t('官方文档')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Gotify推送设置 */}
|
||||
{notificationSettings.warningType === 'gotify' && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='gotifyUrl'
|
||||
label={t('Gotify服务器地址')}
|
||||
placeholder={t(
|
||||
'请输入Gotify服务器地址,例如: https://gotify.example.com',
|
||||
)}
|
||||
onChange={(val) => handleFormChange('gotifyUrl', val)}
|
||||
prefix={<IconLink />}
|
||||
extraText={t(
|
||||
'支持HTTP和HTTPS,填写Gotify服务器的完整URL地址',
|
||||
)}
|
||||
showClear
|
||||
rules={[
|
||||
{
|
||||
required:
|
||||
notificationSettings.warningType === 'gotify',
|
||||
message: t('请输入Gotify服务器地址'),
|
||||
},
|
||||
{
|
||||
pattern: /^https?:\/\/.+/,
|
||||
message: t('Gotify服务器地址必须以http://或https://开头'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='gotifyToken'
|
||||
label={t('Gotify应用令牌')}
|
||||
placeholder={t('请输入Gotify应用令牌')}
|
||||
onChange={(val) => handleFormChange('gotifyToken', val)}
|
||||
prefix={<IconKey />}
|
||||
extraText={t(
|
||||
'在Gotify服务器创建应用后获得的令牌,用于发送通知',
|
||||
)}
|
||||
showClear
|
||||
rules={[
|
||||
{
|
||||
required:
|
||||
notificationSettings.warningType === 'gotify',
|
||||
message: t('请输入Gotify应用令牌'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Form.AutoComplete
|
||||
field='gotifyPriority'
|
||||
label={t('消息优先级')}
|
||||
placeholder={t('请选择消息优先级')}
|
||||
data={[
|
||||
{ value: 0, label: t('0 - 最低') },
|
||||
{ value: 2, label: t('2 - 低') },
|
||||
{ value: 5, label: t('5 - 正常(默认)') },
|
||||
{ value: 8, label: t('8 - 高') },
|
||||
{ value: 10, label: t('10 - 最高') },
|
||||
]}
|
||||
onChange={(val) =>
|
||||
handleFormChange('gotifyPriority', val)
|
||||
}
|
||||
prefix={<IconBell />}
|
||||
extraText={t('消息优先级,范围0-10,默认为5')}
|
||||
style={{ width: '100%', maxWidth: '300px' }}
|
||||
/>
|
||||
|
||||
<div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
|
||||
<div className='text-sm text-gray-700 mb-3'>
|
||||
<strong>{t('配置说明')}</strong>
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 space-y-2'>
|
||||
<div>
|
||||
1. {t('在Gotify服务器的应用管理中创建新应用')}
|
||||
</div>
|
||||
<div>
|
||||
2.{' '}
|
||||
{t(
|
||||
'复制应用的令牌(Token)并填写到上方的应用令牌字段',
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
3. {t('填写Gotify服务器的完整URL地址')}
|
||||
</div>
|
||||
<div className='mt-3 pt-3 border-t border-gray-200'>
|
||||
<span className='text-gray-400'>
|
||||
{t('更多信息请参考')}
|
||||
</span>{' '}
|
||||
<a
|
||||
href='https://gotify.net/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 hover:text-blue-600 font-medium'
|
||||
>
|
||||
Gotify {t('官方文档')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,8 +56,10 @@ import {
|
||||
} from '../../../../helpers';
|
||||
import ModelSelectModal from './ModelSelectModal';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal';
|
||||
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
|
||||
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
|
||||
import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
|
||||
import { createApiCalls } from '../../../../services/secureVerification';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
@@ -105,6 +107,7 @@ const MODEL_FETCHABLE_TYPES = new Set([
|
||||
40,
|
||||
42,
|
||||
48,
|
||||
43,
|
||||
]);
|
||||
|
||||
function type2secretPrompt(type) {
|
||||
@@ -166,6 +169,12 @@ const EditChannelModal = (props) => {
|
||||
settings: '',
|
||||
// 仅 Vertex: 密钥格式(存入 settings.vertex_key_type)
|
||||
vertex_key_type: 'json',
|
||||
// 企业账户设置
|
||||
is_enterprise_account: false,
|
||||
// 字段透传控制默认值
|
||||
allow_service_tier: false,
|
||||
disable_store: false, // false = 允许透传(默认开启)
|
||||
allow_safety_identifier: false,
|
||||
};
|
||||
const [batch, setBatch] = useState(false);
|
||||
const [multiToSingle, setMultiToSingle] = useState(false);
|
||||
@@ -191,13 +200,11 @@ const EditChannelModal = (props) => {
|
||||
const [channelSearchValue, setChannelSearchValue] = useState('');
|
||||
const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式
|
||||
const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加)
|
||||
const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户
|
||||
|
||||
// 2FA验证查看密钥相关状态
|
||||
const [twoFAState, setTwoFAState] = useState({
|
||||
// 密钥显示状态
|
||||
const [keyDisplayState, setKeyDisplayState] = useState({
|
||||
showModal: false,
|
||||
code: '',
|
||||
loading: false,
|
||||
showKey: false,
|
||||
keyData: '',
|
||||
});
|
||||
|
||||
@@ -222,14 +229,41 @@ const EditChannelModal = (props) => {
|
||||
const updateTwoFAState = (updates) => {
|
||||
setTwoFAState((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
// 使用通用安全验证 Hook
|
||||
const {
|
||||
isModalVisible,
|
||||
verificationMethods,
|
||||
verificationState,
|
||||
withVerification,
|
||||
executeVerification,
|
||||
cancelVerification,
|
||||
setVerificationCode,
|
||||
switchVerificationMethod,
|
||||
} = useSecureVerification({
|
||||
onSuccess: (result) => {
|
||||
// 验证成功后显示密钥
|
||||
console.log('Verification success, result:', result);
|
||||
if (result && result.success && result.data?.key) {
|
||||
showSuccess(t('密钥获取成功'));
|
||||
setKeyDisplayState({
|
||||
showModal: true,
|
||||
keyData: result.data.key,
|
||||
});
|
||||
} else if (result && result.key) {
|
||||
// 直接返回了 key(没有包装在 data 中)
|
||||
showSuccess(t('密钥获取成功'));
|
||||
setKeyDisplayState({
|
||||
showModal: true,
|
||||
keyData: result.key,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 重置2FA状态
|
||||
const resetTwoFAState = () => {
|
||||
setTwoFAState({
|
||||
// 重置密钥显示状态
|
||||
const resetKeyDisplayState = () => {
|
||||
setKeyDisplayState({
|
||||
showModal: false,
|
||||
code: '',
|
||||
loading: false,
|
||||
showKey: false,
|
||||
keyData: '',
|
||||
});
|
||||
};
|
||||
@@ -482,15 +516,37 @@ const EditChannelModal = (props) => {
|
||||
parsedSettings.azure_responses_version || '';
|
||||
// 读取 Vertex 密钥格式
|
||||
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
|
||||
// 读取企业账户设置
|
||||
data.is_enterprise_account = parsedSettings.openrouter_enterprise === true;
|
||||
// 读取字段透传控制设置
|
||||
data.allow_service_tier = parsedSettings.allow_service_tier || false;
|
||||
data.disable_store = parsedSettings.disable_store || false;
|
||||
data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false;
|
||||
} catch (error) {
|
||||
console.error('解析其他设置失败:', error);
|
||||
data.azure_responses_version = '';
|
||||
data.region = '';
|
||||
data.vertex_key_type = 'json';
|
||||
data.is_enterprise_account = false;
|
||||
data.allow_service_tier = false;
|
||||
data.disable_store = false;
|
||||
data.allow_safety_identifier = false;
|
||||
}
|
||||
} else {
|
||||
// 兼容历史数据:老渠道没有 settings 时,默认按 json 展示
|
||||
data.vertex_key_type = 'json';
|
||||
data.is_enterprise_account = false;
|
||||
data.allow_service_tier = false;
|
||||
data.disable_store = false;
|
||||
data.allow_safety_identifier = false;
|
||||
}
|
||||
|
||||
if (
|
||||
data.type === 45 &&
|
||||
(!data.base_url ||
|
||||
(typeof data.base_url === 'string' && data.base_url.trim() === ''))
|
||||
) {
|
||||
data.base_url = 'https://ark.cn-beijing.volces.com';
|
||||
}
|
||||
|
||||
setInputs(data);
|
||||
@@ -502,6 +558,8 @@ const EditChannelModal = (props) => {
|
||||
} else {
|
||||
setAutoBan(true);
|
||||
}
|
||||
// 同步企业账户状态
|
||||
setIsEnterpriseAccount(data.is_enterprise_account || false);
|
||||
setBasicModels(getChannelModels(data.type));
|
||||
// 同步更新channelSettings状态显示
|
||||
setChannelSettings({
|
||||
@@ -630,42 +688,33 @@ const EditChannelModal = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 使用TwoFactorAuthModal的验证函数
|
||||
const handleVerify2FA = async () => {
|
||||
if (!verifyCode) {
|
||||
showError(t('请输入验证码或备用码'));
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifyLoading(true);
|
||||
// 查看渠道密钥(透明验证)
|
||||
const handleShow2FAModal = async () => {
|
||||
try {
|
||||
const res = await API.post(`/api/channel/${channelId}/key`, {
|
||||
code: verifyCode,
|
||||
});
|
||||
if (res.data.success) {
|
||||
// 验证成功,显示密钥
|
||||
updateTwoFAState({
|
||||
// 使用 withVerification 包装,会自动处理需要验证的情况
|
||||
const result = await withVerification(
|
||||
createApiCalls.viewChannelKey(channelId),
|
||||
{
|
||||
title: t('查看渠道密钥'),
|
||||
description: t('为了保护账户安全,请验证您的身份。'),
|
||||
preferredMethod: 'passkey', // 优先使用 Passkey
|
||||
}
|
||||
);
|
||||
|
||||
// 如果直接返回了结果(已验证),显示密钥
|
||||
if (result && result.success && result.data?.key) {
|
||||
showSuccess(t('密钥获取成功'));
|
||||
setKeyDisplayState({
|
||||
showModal: true,
|
||||
showKey: true,
|
||||
keyData: res.data.data.key,
|
||||
keyData: result.data.key,
|
||||
});
|
||||
reset2FAVerifyState();
|
||||
showSuccess(t('验证成功'));
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('获取密钥失败'));
|
||||
} finally {
|
||||
setVerifyLoading(false);
|
||||
console.error('Failed to view channel key:', error);
|
||||
showError(error.message || t('获取密钥失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// 显示2FA验证模态框 - 使用TwoFactorAuthModal
|
||||
const handleShow2FAModal = () => {
|
||||
setShow2FAVerifyModal(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const modelMap = new Map();
|
||||
|
||||
@@ -763,16 +812,16 @@ const EditChannelModal = (props) => {
|
||||
});
|
||||
// 重置密钥模式状态
|
||||
setKeyMode('append');
|
||||
// 重置企业账户状态
|
||||
setIsEnterpriseAccount(false);
|
||||
// 清空表单中的key_mode字段
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key_mode', undefined);
|
||||
}
|
||||
// 重置本地输入,避免下次打开残留上一次的 JSON 字段值
|
||||
setInputs(getInitValues());
|
||||
// 重置2FA状态
|
||||
resetTwoFAState();
|
||||
// 重置2FA验证状态
|
||||
reset2FAVerifyState();
|
||||
// 重置密钥显示状态
|
||||
resetKeyDisplayState();
|
||||
};
|
||||
|
||||
const handleVertexUploadChange = ({ fileList }) => {
|
||||
@@ -873,7 +922,9 @@ const EditChannelModal = (props) => {
|
||||
delete localInputs.key;
|
||||
}
|
||||
} else {
|
||||
localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]);
|
||||
localInputs.key = batch
|
||||
? JSON.stringify(keys)
|
||||
: JSON.stringify(keys[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -926,6 +977,33 @@ const EditChannelModal = (props) => {
|
||||
};
|
||||
localInputs.setting = JSON.stringify(channelExtraSettings);
|
||||
|
||||
// 处理 settings 字段(包括企业账户设置和字段透传控制)
|
||||
let settings = {};
|
||||
if (localInputs.settings) {
|
||||
try {
|
||||
settings = JSON.parse(localInputs.settings);
|
||||
} catch (error) {
|
||||
console.error('解析settings失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// type === 20: 设置企业账户标识,无论是true还是false都要传到后端
|
||||
if (localInputs.type === 20) {
|
||||
settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
|
||||
}
|
||||
|
||||
// type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值)
|
||||
if (localInputs.type === 1 || localInputs.type === 14) {
|
||||
settings.allow_service_tier = localInputs.allow_service_tier === true;
|
||||
// 仅 OpenAI 渠道需要 store 和 safety_identifier
|
||||
if (localInputs.type === 1) {
|
||||
settings.disable_store = localInputs.disable_store === true;
|
||||
settings.allow_safety_identifier = localInputs.allow_safety_identifier === true;
|
||||
}
|
||||
}
|
||||
|
||||
localInputs.settings = JSON.stringify(settings);
|
||||
|
||||
// 清理不需要发送到后端的字段
|
||||
delete localInputs.force_format;
|
||||
delete localInputs.thinking_to_content;
|
||||
@@ -933,8 +1011,13 @@ const EditChannelModal = (props) => {
|
||||
delete localInputs.pass_through_body_enabled;
|
||||
delete localInputs.system_prompt;
|
||||
delete localInputs.system_prompt_override;
|
||||
delete localInputs.is_enterprise_account;
|
||||
// 顶层的 vertex_key_type 不应发送给后端
|
||||
delete localInputs.vertex_key_type;
|
||||
// 清理字段透传控制的临时字段
|
||||
delete localInputs.allow_service_tier;
|
||||
delete localInputs.disable_store;
|
||||
delete localInputs.allow_safety_identifier;
|
||||
|
||||
let res;
|
||||
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
|
||||
@@ -974,6 +1057,56 @@ const EditChannelModal = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 密钥去重函数
|
||||
const deduplicateKeys = () => {
|
||||
const currentKey = formApiRef.current?.getValue('key') || inputs.key || '';
|
||||
|
||||
if (!currentKey.trim()) {
|
||||
showInfo(t('请先输入密钥'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 按行分割密钥
|
||||
const keyLines = currentKey.split('\n');
|
||||
const beforeCount = keyLines.length;
|
||||
|
||||
// 使用哈希表去重,保持原有顺序
|
||||
const keySet = new Set();
|
||||
const deduplicatedKeys = [];
|
||||
|
||||
keyLines.forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && !keySet.has(trimmedLine)) {
|
||||
keySet.add(trimmedLine);
|
||||
deduplicatedKeys.push(trimmedLine);
|
||||
}
|
||||
});
|
||||
|
||||
const afterCount = deduplicatedKeys.length;
|
||||
const deduplicatedKeyText = deduplicatedKeys.join('\n');
|
||||
|
||||
// 更新表单和状态
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key', deduplicatedKeyText);
|
||||
}
|
||||
handleInputChange('key', deduplicatedKeyText);
|
||||
|
||||
// 显示去重结果
|
||||
const message = t(
|
||||
'去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥',
|
||||
{
|
||||
before: beforeCount,
|
||||
after: afterCount,
|
||||
},
|
||||
);
|
||||
|
||||
if (beforeCount === afterCount) {
|
||||
showInfo(t('未发现重复密钥'));
|
||||
} else {
|
||||
showSuccess(message);
|
||||
}
|
||||
};
|
||||
|
||||
const addCustomModels = () => {
|
||||
if (customModel.trim() === '') return;
|
||||
const modelArray = customModel.split(',').map((model) => model.trim());
|
||||
@@ -1069,24 +1202,41 @@ const EditChannelModal = (props) => {
|
||||
</Checkbox>
|
||||
)}
|
||||
{batch && (
|
||||
<Checkbox
|
||||
disabled={isEdit}
|
||||
checked={multiToSingle}
|
||||
onChange={() => {
|
||||
setMultiToSingle((prev) => !prev);
|
||||
setInputs((prev) => {
|
||||
const newInputs = { ...prev };
|
||||
if (!multiToSingle) {
|
||||
newInputs.multi_key_mode = multiKeyMode;
|
||||
} else {
|
||||
delete newInputs.multi_key_mode;
|
||||
}
|
||||
return newInputs;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('密钥聚合模式')}
|
||||
</Checkbox>
|
||||
<>
|
||||
<Checkbox
|
||||
disabled={isEdit}
|
||||
checked={multiToSingle}
|
||||
onChange={() => {
|
||||
setMultiToSingle((prev) => {
|
||||
const nextValue = !prev;
|
||||
setInputs((prevInputs) => {
|
||||
const newInputs = { ...prevInputs };
|
||||
if (nextValue) {
|
||||
newInputs.multi_key_mode = multiKeyMode;
|
||||
} else {
|
||||
delete newInputs.multi_key_mode;
|
||||
}
|
||||
return newInputs;
|
||||
});
|
||||
return nextValue;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('密钥聚合模式')}
|
||||
</Checkbox>
|
||||
|
||||
{inputs.type !== 41 && (
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
theme='outline'
|
||||
onClick={deduplicateKeys}
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{t('密钥去重')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
) : null;
|
||||
@@ -1288,6 +1438,21 @@ const EditChannelModal = (props) => {
|
||||
onChange={(value) => handleInputChange('type', value)}
|
||||
/>
|
||||
|
||||
{inputs.type === 20 && (
|
||||
<Form.Switch
|
||||
field='is_enterprise_account'
|
||||
label={t('是否为企业账户')}
|
||||
checkedText={t('是')}
|
||||
uncheckedText={t('否')}
|
||||
onChange={(value) => {
|
||||
setIsEnterpriseAccount(value);
|
||||
handleInputChange('is_enterprise_account', value);
|
||||
}}
|
||||
extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')}
|
||||
initValue={inputs.is_enterprise_account}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('名称')}
|
||||
@@ -1311,7 +1476,10 @@ const EditChannelModal = (props) => {
|
||||
value={inputs.vertex_key_type || 'json'}
|
||||
onChange={(value) => {
|
||||
// 更新设置中的 vertex_key_type
|
||||
handleChannelOtherSettingsChange('vertex_key_type', value);
|
||||
handleChannelOtherSettingsChange(
|
||||
'vertex_key_type',
|
||||
value,
|
||||
);
|
||||
// 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
|
||||
if (value === 'api_key') {
|
||||
setBatch(false);
|
||||
@@ -1331,7 +1499,8 @@ const EditChannelModal = (props) => {
|
||||
/>
|
||||
)}
|
||||
{batch ? (
|
||||
inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<Form.Upload
|
||||
field='vertex_files'
|
||||
label={t('密钥文件 (.json)')}
|
||||
@@ -1367,7 +1536,7 @@ const EditChannelModal = (props) => {
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
extraText={
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
{isEdit &&
|
||||
isMultiKeyChannel &&
|
||||
keyMode === 'append' && (
|
||||
@@ -1395,7 +1564,8 @@ const EditChannelModal = (props) => {
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
{inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<>
|
||||
{!batch && (
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
@@ -2354,6 +2524,77 @@ const EditChannelModal = (props) => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 字段透传控制 - OpenAI 渠道 */}
|
||||
{inputs.type === 1 && (
|
||||
<>
|
||||
<div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
|
||||
{t('字段透传控制')}
|
||||
</div>
|
||||
|
||||
<Form.Switch
|
||||
field='allow_service_tier'
|
||||
label={t('允许 service_tier 透传')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) =>
|
||||
handleChannelOtherSettingsChange('allow_service_tier', value)
|
||||
}
|
||||
extraText={t(
|
||||
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form.Switch
|
||||
field='disable_store'
|
||||
label={t('禁用 store 透传')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) =>
|
||||
handleChannelOtherSettingsChange('disable_store', value)
|
||||
}
|
||||
extraText={t(
|
||||
'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form.Switch
|
||||
field='allow_safety_identifier'
|
||||
label={t('允许 safety_identifier 透传')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) =>
|
||||
handleChannelOtherSettingsChange('allow_safety_identifier', value)
|
||||
}
|
||||
extraText={t(
|
||||
'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 字段透传控制 - Claude 渠道 */}
|
||||
{(inputs.type === 14) && (
|
||||
<>
|
||||
<div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
|
||||
{t('字段透传控制')}
|
||||
</div>
|
||||
|
||||
<Form.Switch
|
||||
field='allow_service_tier'
|
||||
label={t('允许 service_tier 透传')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) =>
|
||||
handleChannelOtherSettingsChange('allow_service_tier', value)
|
||||
}
|
||||
extraText={t(
|
||||
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Channel Extra Settings Card */}
|
||||
<div ref={el => formSectionRefs.current.channelExtraSettings = el}>
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
@@ -2458,6 +2699,8 @@ const EditChannelModal = (props) => {
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
)}
|
||||
@@ -2468,17 +2711,17 @@ const EditChannelModal = (props) => {
|
||||
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
|
||||
/>
|
||||
</SideSheet>
|
||||
{/* 使用TwoFactorAuthModal组件进行2FA验证 */}
|
||||
<TwoFactorAuthModal
|
||||
visible={show2FAVerifyModal}
|
||||
code={verifyCode}
|
||||
loading={verifyLoading}
|
||||
onCodeChange={setVerifyCode}
|
||||
onVerify={handleVerify2FA}
|
||||
onCancel={reset2FAVerifyState}
|
||||
title={t('查看渠道密钥')}
|
||||
description={t('为了保护账户安全,请验证您的两步验证码。')}
|
||||
placeholder={t('请输入验证码或备用码')}
|
||||
{/* 使用通用安全验证模态框 */}
|
||||
<SecureVerificationModal
|
||||
visible={isModalVisible}
|
||||
verificationMethods={verificationMethods}
|
||||
verificationState={verificationState}
|
||||
onVerify={executeVerification}
|
||||
onCancel={cancelVerification}
|
||||
onCodeChange={setVerificationCode}
|
||||
onMethodSwitch={switchVerificationMethod}
|
||||
title={verificationState.title}
|
||||
description={verificationState.description}
|
||||
/>
|
||||
|
||||
{/* 使用ChannelKeyDisplay组件显示密钥 */}
|
||||
@@ -2501,10 +2744,10 @@ const EditChannelModal = (props) => {
|
||||
{t('渠道密钥信息')}
|
||||
</div>
|
||||
}
|
||||
visible={twoFAState.showModal && twoFAState.showKey}
|
||||
onCancel={resetTwoFAState}
|
||||
visible={keyDisplayState.showModal}
|
||||
onCancel={resetKeyDisplayState}
|
||||
footer={
|
||||
<Button type='primary' onClick={resetTwoFAState}>
|
||||
<Button type='primary' onClick={resetKeyDisplayState}>
|
||||
{t('完成')}
|
||||
</Button>
|
||||
}
|
||||
@@ -2512,7 +2755,7 @@ const EditChannelModal = (props) => {
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<ChannelKeyDisplay
|
||||
keyData={twoFAState.keyData}
|
||||
keyData={keyDisplayState.keyData}
|
||||
showSuccessIcon={true}
|
||||
successText={t('密钥获取成功')}
|
||||
showWarning={true}
|
||||
|
||||
@@ -118,6 +118,9 @@ const EditTagModal = (props) => {
|
||||
case 36:
|
||||
localModels = ['suno_music', 'suno_lyrics'];
|
||||
break;
|
||||
case 53:
|
||||
localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8','Qwen/Qwen3-235B-A22B-Instruct-2507', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1'];
|
||||
break;
|
||||
default:
|
||||
localModels = getChannelModels(value);
|
||||
break;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
|
||||
@@ -45,6 +46,8 @@ const ModelTestModal = ({
|
||||
testChannel,
|
||||
modelTablePage,
|
||||
setModelTablePage,
|
||||
selectedEndpointType,
|
||||
setSelectedEndpointType,
|
||||
allSelectingRef,
|
||||
isMobile,
|
||||
t,
|
||||
@@ -59,6 +62,17 @@ const ModelTestModal = ({
|
||||
)
|
||||
: [];
|
||||
|
||||
const endpointTypeOptions = [
|
||||
{ value: '', label: t('自动检测') },
|
||||
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
|
||||
{ value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
|
||||
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
|
||||
{ value: 'gemini', label: 'Gemini (/v1beta/models/{model}:generateContent)' },
|
||||
{ value: 'jina-rerank', label: 'Jina Rerank (/rerank)' },
|
||||
{ value: 'image-generation', label: t('图像生成') + ' (/v1/images/generations)' },
|
||||
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
|
||||
];
|
||||
|
||||
const handleCopySelected = () => {
|
||||
if (selectedModelKeys.length === 0) {
|
||||
showError(t('请先选择模型!'));
|
||||
@@ -152,7 +166,7 @@ const ModelTestModal = ({
|
||||
return (
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => testChannel(currentTestChannel, record.model)}
|
||||
onClick={() => testChannel(currentTestChannel, record.model, selectedEndpointType)}
|
||||
loading={isTesting}
|
||||
size='small'
|
||||
>
|
||||
@@ -228,6 +242,18 @@ const ModelTestModal = ({
|
||||
>
|
||||
{hasChannel && (
|
||||
<div className='model-test-scroll'>
|
||||
{/* 端点类型选择器 */}
|
||||
<div className='flex items-center gap-2 w-full mb-2'>
|
||||
<Typography.Text strong>{t('端点类型')}:</Typography.Text>
|
||||
<Select
|
||||
value={selectedEndpointType}
|
||||
onChange={setSelectedEndpointType}
|
||||
optionList={endpointTypeOptions}
|
||||
className='!w-full'
|
||||
placeholder={t('选择端点类型')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 搜索与操作按钮 */}
|
||||
<div className='flex items-center justify-end gap-2 w-full mb-2'>
|
||||
<Input
|
||||
|
||||
@@ -17,8 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Button, Typography, Spin } from '@douyinfe/semi-ui';
|
||||
import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ContentModal = ({
|
||||
isModalOpen,
|
||||
@@ -26,17 +29,120 @@ const ContentModal = ({
|
||||
modalContent,
|
||||
isVideo,
|
||||
}) => {
|
||||
const [videoError, setVideoError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen && isVideo) {
|
||||
setVideoError(false);
|
||||
setIsLoading(true);
|
||||
}
|
||||
}, [isModalOpen, isVideo]);
|
||||
|
||||
const handleVideoError = () => {
|
||||
setVideoError(true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleVideoLoaded = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
navigator.clipboard.writeText(modalContent);
|
||||
};
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
window.open(modalContent, '_blank');
|
||||
};
|
||||
|
||||
const renderVideoContent = () => {
|
||||
if (videoError) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}>
|
||||
视频无法在当前浏览器中播放,这可能是由于:
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
||||
• 视频服务商的跨域限制
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
||||
• 需要特定的请求头或认证
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}>
|
||||
• 防盗链保护机制
|
||||
</Text>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Button
|
||||
icon={<IconExternalOpen />}
|
||||
onClick={handleOpenInNewTab}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
在新标签页中打开
|
||||
</Button>
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
onClick={handleCopyUrl}
|
||||
>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<Text
|
||||
type="tertiary"
|
||||
style={{ fontSize: '10px', wordBreak: 'break-all' }}
|
||||
>
|
||||
{modalContent}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{isLoading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 10
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
src={modalContent}
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
autoPlay
|
||||
crossOrigin="anonymous"
|
||||
onError={handleVideoError}
|
||||
onLoadedData={handleVideoLoaded}
|
||||
onLoadStart={() => setIsLoading(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isModalOpen}
|
||||
onOk={() => setIsModalOpen(false)}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
bodyStyle={{ height: '400px', overflow: 'auto' }}
|
||||
bodyStyle={{
|
||||
height: isVideo ? '450px' : '400px',
|
||||
overflow: 'auto',
|
||||
padding: isVideo && videoError ? '0' : '24px'
|
||||
}}
|
||||
width={800}
|
||||
>
|
||||
{isVideo ? (
|
||||
<video src={modalContent} controls style={{ width: '100%' }} autoPlay />
|
||||
renderVideoContent()
|
||||
) : (
|
||||
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
Progress,
|
||||
Popover,
|
||||
Typography,
|
||||
Dropdown,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconMore } from '@douyinfe/semi-icons';
|
||||
import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
|
||||
|
||||
/**
|
||||
@@ -204,6 +206,8 @@ const renderOperations = (
|
||||
showDemoteModal,
|
||||
showEnableDisableModal,
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
t,
|
||||
},
|
||||
) => {
|
||||
@@ -211,6 +215,28 @@ const renderOperations = (
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const moreMenu = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('重置 Passkey'),
|
||||
onClick: () => showResetPasskeyModal(record),
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('重置 2FA'),
|
||||
onClick: () => showResetTwoFAModal(record),
|
||||
},
|
||||
{
|
||||
node: 'divider',
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('注销'),
|
||||
type: 'danger',
|
||||
onClick: () => showDeleteModal(record),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space>
|
||||
{record.status === 1 ? (
|
||||
@@ -253,13 +279,17 @@ const renderOperations = (
|
||||
>
|
||||
{t('降级')}
|
||||
</Button>
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
onClick={() => showDeleteModal(record)}
|
||||
<Dropdown
|
||||
menu={moreMenu}
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
>
|
||||
{t('注销')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<IconMore />}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
@@ -275,6 +305,8 @@ export const getUsersColumns = ({
|
||||
showDemoteModal,
|
||||
showEnableDisableModal,
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -329,6 +361,8 @@ export const getUsersColumns = ({
|
||||
showDemoteModal,
|
||||
showEnableDisableModal,
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
t,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -29,6 +29,8 @@ import PromoteUserModal from './modals/PromoteUserModal';
|
||||
import DemoteUserModal from './modals/DemoteUserModal';
|
||||
import EnableDisableUserModal from './modals/EnableDisableUserModal';
|
||||
import DeleteUserModal from './modals/DeleteUserModal';
|
||||
import ResetPasskeyModal from './modals/ResetPasskeyModal';
|
||||
import ResetTwoFAModal from './modals/ResetTwoFAModal';
|
||||
|
||||
const UsersTable = (usersData) => {
|
||||
const {
|
||||
@@ -45,6 +47,8 @@ const UsersTable = (usersData) => {
|
||||
setShowEditUser,
|
||||
manageUser,
|
||||
refresh,
|
||||
resetUserPasskey,
|
||||
resetUserTwoFA,
|
||||
t,
|
||||
} = usersData;
|
||||
|
||||
@@ -55,6 +59,8 @@ const UsersTable = (usersData) => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [modalUser, setModalUser] = useState(null);
|
||||
const [enableDisableAction, setEnableDisableAction] = useState('');
|
||||
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
|
||||
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
|
||||
|
||||
// Modal handlers
|
||||
const showPromoteUserModal = (user) => {
|
||||
@@ -78,6 +84,16 @@ const UsersTable = (usersData) => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const showResetPasskeyUserModal = (user) => {
|
||||
setModalUser(user);
|
||||
setShowResetPasskeyModal(true);
|
||||
};
|
||||
|
||||
const showResetTwoFAUserModal = (user) => {
|
||||
setModalUser(user);
|
||||
setShowResetTwoFAModal(true);
|
||||
};
|
||||
|
||||
// Modal confirm handlers
|
||||
const handlePromoteConfirm = () => {
|
||||
manageUser(modalUser.id, 'promote', modalUser);
|
||||
@@ -94,6 +110,16 @@ const UsersTable = (usersData) => {
|
||||
setShowEnableDisableModal(false);
|
||||
};
|
||||
|
||||
const handleResetPasskeyConfirm = async () => {
|
||||
await resetUserPasskey(modalUser);
|
||||
setShowResetPasskeyModal(false);
|
||||
};
|
||||
|
||||
const handleResetTwoFAConfirm = async () => {
|
||||
await resetUserTwoFA(modalUser);
|
||||
setShowResetTwoFAModal(false);
|
||||
};
|
||||
|
||||
// Get all columns
|
||||
const columns = useMemo(() => {
|
||||
return getUsersColumns({
|
||||
@@ -104,8 +130,20 @@ const UsersTable = (usersData) => {
|
||||
showDemoteModal: showDemoteUserModal,
|
||||
showEnableDisableModal: showEnableDisableUserModal,
|
||||
showDeleteModal: showDeleteUserModal,
|
||||
showResetPasskeyModal: showResetPasskeyUserModal,
|
||||
showResetTwoFAModal: showResetTwoFAUserModal,
|
||||
});
|
||||
}, [t, setEditingUser, setShowEditUser]);
|
||||
}, [
|
||||
t,
|
||||
setEditingUser,
|
||||
setShowEditUser,
|
||||
showPromoteUserModal,
|
||||
showDemoteUserModal,
|
||||
showEnableDisableUserModal,
|
||||
showDeleteUserModal,
|
||||
showResetPasskeyUserModal,
|
||||
showResetTwoFAUserModal,
|
||||
]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
const tableColumns = useMemo(() => {
|
||||
@@ -188,6 +226,22 @@ const UsersTable = (usersData) => {
|
||||
manageUser={manageUser}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ResetPasskeyModal
|
||||
visible={showResetPasskeyModal}
|
||||
onCancel={() => setShowResetPasskeyModal(false)}
|
||||
onConfirm={handleResetPasskeyConfirm}
|
||||
user={modalUser}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ResetTwoFAModal
|
||||
visible={showResetTwoFAModal}
|
||||
onCancel={() => setShowResetTwoFAModal(false)}
|
||||
onConfirm={handleResetTwoFAConfirm}
|
||||
user={modalUser}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
39
web/src/components/table/users/modals/ResetPasskeyModal.jsx
Normal file
39
web/src/components/table/users/modals/ResetPasskeyModal.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
|
||||
const ResetPasskeyModal = ({ visible, onCancel, onConfirm, user, t }) => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('确认重置 Passkey')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onConfirm}
|
||||
type='warning'
|
||||
>
|
||||
{t('此操作将解绑用户当前的 Passkey,下次登录需要重新注册。')}{' '}
|
||||
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasskeyModal;
|
||||
|
||||
39
web/src/components/table/users/modals/ResetTwoFAModal.jsx
Normal file
39
web/src/components/table/users/modals/ResetTwoFAModal.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
|
||||
const ResetTwoFAModal = ({ visible, onCancel, onConfirm, user, t }) => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('确认重置两步验证')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onConfirm}
|
||||
type='warning'
|
||||
>
|
||||
{t('此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。')}{' '}
|
||||
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetTwoFAModal;
|
||||
|
||||
Reference in New Issue
Block a user