Spaces:
Build error
Build error
| /* | |
| 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 { | |
| Button, | |
| Card, | |
| Input, | |
| Space, | |
| Typography, | |
| Avatar, | |
| Tabs, | |
| TabPane, | |
| Popover, | |
| Modal, | |
| } from '@douyinfe/semi-ui'; | |
| import { | |
| IconMail, | |
| IconShield, | |
| IconGithubLogo, | |
| IconKey, | |
| IconLock, | |
| IconDelete, | |
| } from '@douyinfe/semi-icons'; | |
| import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si'; | |
| import { UserPlus, ShieldCheck } from 'lucide-react'; | |
| import TelegramLoginButton from 'react-telegram-login'; | |
| import { | |
| onGitHubOAuthClicked, | |
| onOIDCClicked, | |
| onLinuxDOOAuthClicked, | |
| } from '../../../../helpers'; | |
| import TwoFASetting from '../components/TwoFASetting'; | |
| const AccountManagement = ({ | |
| t, | |
| userState, | |
| status, | |
| systemToken, | |
| setShowEmailBindModal, | |
| setShowWeChatBindModal, | |
| generateAccessToken, | |
| handleSystemTokenClick, | |
| setShowChangePasswordModal, | |
| setShowAccountDeleteModal, | |
| passkeyStatus, | |
| passkeySupported, | |
| passkeyRegisterLoading, | |
| passkeyDeleteLoading, | |
| onPasskeyRegister, | |
| onPasskeyDelete, | |
| }) => { | |
| const renderAccountInfo = (accountId, label) => { | |
| if (!accountId || accountId === '') { | |
| return <span className='text-gray-500'>{t('未绑定')}</span>; | |
| } | |
| const popContent = ( | |
| <div className='text-xs p-2'> | |
| <Typography.Paragraph copyable={{ content: accountId }}> | |
| {accountId} | |
| </Typography.Paragraph> | |
| {label ? ( | |
| <div className='mt-1 text-[11px] text-gray-500'>{label}</div> | |
| ) : null} | |
| </div> | |
| ); | |
| return ( | |
| <Popover content={popContent} position='top' trigger='hover'> | |
| <span className='block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer'> | |
| {accountId} | |
| </span> | |
| </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'> | |
| {/* 卡片头部 */} | |
| <div className='flex items-center mb-4'> | |
| <Avatar size='small' color='teal' className='mr-3 shadow-md'> | |
| <UserPlus size={16} /> | |
| </Avatar> | |
| <div> | |
| <Typography.Text className='text-lg font-medium'> | |
| {t('账户管理')} | |
| </Typography.Text> | |
| <div className='text-xs text-gray-600'> | |
| {t('账户绑定、安全设置和身份验证')} | |
| </div> | |
| </div> | |
| </div> | |
| <Tabs type='card' defaultActiveKey='binding'> | |
| {/* 账户绑定 Tab */} | |
| <TabPane | |
| tab={ | |
| <div className='flex items-center'> | |
| <UserPlus size={16} className='mr-2' /> | |
| {t('账户绑定')} | |
| </div> | |
| } | |
| itemKey='binding' | |
| > | |
| <div className='py-4'> | |
| <div className='grid grid-cols-1 lg:grid-cols-2 gap-4'> | |
| {/* 邮箱绑定 */} | |
| <Card className='!rounded-xl'> | |
| <div className='flex items-center justify-between gap-3'> | |
| <div className='flex items-center flex-1 min-w-0'> | |
| <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'> | |
| <IconMail | |
| size='default' | |
| className='text-slate-600 dark:text-slate-300' | |
| /> | |
| </div> | |
| <div className='flex-1 min-w-0'> | |
| <div className='font-medium text-gray-900'> | |
| {t('邮箱')} | |
| </div> | |
| <div className='text-sm text-gray-500 truncate'> | |
| {renderAccountInfo( | |
| userState.user?.email, | |
| t('邮箱地址'), | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className='flex-shrink-0'> | |
| <Button | |
| type='primary' | |
| theme='outline' | |
| size='small' | |
| onClick={() => setShowEmailBindModal(true)} | |
| > | |
| {isBound(userState.user?.email) | |
| ? t('修改绑定') | |
| : t('绑定')} | |
| </Button> | |
| </div> | |
| </div> | |
| </Card> | |
| {/* 微信绑定 */} | |
| <Card className='!rounded-xl'> | |
| <div className='flex items-center justify-between gap-3'> | |
| <div className='flex items-center flex-1 min-w-0'> | |
| <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'> | |
| <SiWechat | |
| size={20} | |
| className='text-slate-600 dark:text-slate-300' | |
| /> | |
| </div> | |
| <div className='flex-1 min-w-0'> | |
| <div className='font-medium text-gray-900'> | |
| {t('微信')} | |
| </div> | |
| <div className='text-sm text-gray-500 truncate'> | |
| {!status.wechat_login | |
| ? t('未启用') | |
| : isBound(userState.user?.wechat_id) | |
| ? t('已绑定') | |
| : t('未绑定')} | |
| </div> | |
| </div> | |
| </div> | |
| <div className='flex-shrink-0'> | |
| <Button | |
| type='primary' | |
| theme='outline' | |
| size='small' | |
| disabled={!status.wechat_login} | |
| onClick={() => setShowWeChatBindModal(true)} | |
| > | |
| {isBound(userState.user?.wechat_id) | |
| ? t('修改绑定') | |
| : status.wechat_login | |
| ? t('绑定') | |
| : t('未启用')} | |
| </Button> | |
| </div> | |
| </div> | |
| </Card> | |
| {/* GitHub绑定 */} | |
| <Card className='!rounded-xl'> | |
| <div className='flex items-center justify-between gap-3'> | |
| <div className='flex items-center flex-1 min-w-0'> | |
| <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'> | |
| <IconGithubLogo | |
| size='default' | |
| className='text-slate-600 dark:text-slate-300' | |
| /> | |
| </div> | |
| <div className='flex-1 min-w-0'> | |
| <div className='font-medium text-gray-900'> | |
| {t('GitHub')} | |
| </div> | |
| <div className='text-sm text-gray-500 truncate'> | |
| {renderAccountInfo( | |
| userState.user?.github_id, | |
| t('GitHub ID'), | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className='flex-shrink-0'> | |
| <Button | |
| type='primary' | |
| theme='outline' | |
| size='small' | |
| onClick={() => | |
| onGitHubOAuthClicked(status.github_client_id) | |
| } | |
| disabled={ | |
| isBound(userState.user?.github_id) || !status.github_oauth | |
| } | |
| > | |
| {status.github_oauth ? t('绑定') : t('未启用')} | |
| </Button> | |
| </div> | |
| </div> | |
| </Card> | |
| {/* OIDC绑定 */} | |
| <Card className='!rounded-xl'> | |
| <div className='flex items-center justify-between gap-3'> | |
| <div className='flex items-center flex-1 min-w-0'> | |
| <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'> | |
| <IconShield | |
| size='default' | |
| className='text-slate-600 dark:text-slate-300' | |
| /> | |
| </div> | |
| <div className='flex-1 min-w-0'> | |
| <div className='font-medium text-gray-900'> | |
| {t('OIDC')} | |
| </div> | |
| <div className='text-sm text-gray-500 truncate'> | |
| {renderAccountInfo( | |
| userState.user?.oidc_id, | |
| t('OIDC ID'), | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className='flex-shrink-0'> | |
| <Button | |
| type='primary' | |
| theme='outline' | |
| size='small' | |
| onClick={() => | |
| onOIDCClicked( | |
| status.oidc_authorization_endpoint, | |
| status.oidc_client_id, | |
| ) | |
| } | |
| disabled={ | |
| isBound(userState.user?.oidc_id) || !status.oidc_enabled | |
| } | |
| > | |
| {status.oidc_enabled ? t('绑定') : t('未启用')} | |
| </Button> | |
| </div> | |
| </div> | |
| </Card> | |
| {/* Telegram绑定 */} | |
| <Card className='!rounded-xl'> | |
| <div className='flex items-center justify-between gap-3'> | |
| <div className='flex items-center flex-1 min-w-0'> | |
| <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'> | |
| <SiTelegram | |
| size={20} | |
| className='text-slate-600 dark:text-slate-300' | |
| /> | |
| </div> | |
| <div className='flex-1 min-w-0'> | |
| <div className='font-medium text-gray-900'> | |
| {t('Telegram')} | |
| </div> | |
| <div className='text-sm text-gray-500 truncate'> | |
| {renderAccountInfo( | |
| userState.user?.telegram_id, | |
| t('Telegram ID'), | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className='flex-shrink-0'> | |
| {status.telegram_oauth ? ( | |
| isBound(userState.user?.telegram_id) ? ( | |
| <Button | |
| disabled | |
| size='small' | |
| type='primary' | |
| theme='outline' | |
| > | |
| {t('已绑定')} | |
| </Button> | |
| ) : ( | |
| <Button | |
| type='primary' | |
| theme='outline' | |
| size='small' | |
| onClick={() => setShowTelegramBindModal(true)} | |
| > | |
| {t('绑定')} | |
| </Button> | |
| ) | |
| ) : ( | |
| <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'> | |
| <div className='flex items-center justify-between gap-3'> | |
| <div className='flex items-center flex-1 min-w-0'> | |
| <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'> | |
| <SiLinux | |
| size={20} | |
| className='text-slate-600 dark:text-slate-300' | |
| /> | |
| </div> | |
| <div className='flex-1 min-w-0'> | |
| <div className='font-medium text-gray-900'> | |
| {t('LinuxDO')} | |
| </div> | |
| <div className='text-sm text-gray-500 truncate'> | |
| {renderAccountInfo( | |
| userState.user?.linux_do_id, | |
| t('LinuxDO ID'), | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className='flex-shrink-0'> | |
| <Button | |
| type='primary' | |
| theme='outline' | |
| size='small' | |
| onClick={() => | |
| onLinuxDOOAuthClicked(status.linuxdo_client_id) | |
| } | |
| disabled={ | |
| isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth | |
| } | |
| > | |
| {status.linuxdo_oauth ? t('绑定') : t('未启用')} | |
| </Button> | |
| </div> | |
| </div> | |
| </Card> | |
| </div> | |
| </div> | |
| </TabPane> | |
| {/* 安全设置 Tab */} | |
| <TabPane | |
| tab={ | |
| <div className='flex items-center'> | |
| <ShieldCheck size={16} className='mr-2' /> | |
| {t('安全设置')} | |
| </div> | |
| } | |
| itemKey='security' | |
| > | |
| <div className='py-4'> | |
| <div className='space-y-6'> | |
| <Space vertical className='w-full'> | |
| {/* 系统访问令牌 */} | |
| <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 className='flex-1'> | |
| <Typography.Title heading={6} className='mb-1'> | |
| {t('系统访问令牌')} | |
| </Typography.Title> | |
| <Typography.Text type='tertiary' className='text-sm'> | |
| {t('用于API调用的身份验证令牌,请妥善保管')} | |
| </Typography.Text> | |
| {systemToken && ( | |
| <div className='mt-3'> | |
| <Input | |
| readonly | |
| value={systemToken} | |
| onClick={handleSystemTokenClick} | |
| size='large' | |
| prefix={<IconKey />} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <Button | |
| type='primary' | |
| theme='solid' | |
| onClick={generateAccessToken} | |
| className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto' | |
| icon={<IconKey />} | |
| > | |
| {systemToken ? t('重新生成') : t('生成令牌')} | |
| </Button> | |
| </div> | |
| </Card> | |
| {/* 密码管理 */} | |
| <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'> | |
| <IconLock size='large' className='text-slate-600' /> | |
| </div> | |
| <div> | |
| <Typography.Title heading={6} className='mb-1'> | |
| {t('密码管理')} | |
| </Typography.Title> | |
| <Typography.Text type='tertiary' className='text-sm'> | |
| {t('定期更改密码可以提高账户安全性')} | |
| </Typography.Text> | |
| </div> | |
| </div> | |
| <Button | |
| type='primary' | |
| theme='solid' | |
| onClick={() => setShowChangePasswordModal(true)} | |
| className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto' | |
| icon={<IconLock />} | |
| > | |
| {t('修改密码')} | |
| </Button> | |
| </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} /> | |
| {/* 危险区域 */} | |
| <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'> | |
| <IconDelete size='large' className='text-slate-600' /> | |
| </div> | |
| <div> | |
| <Typography.Title | |
| heading={6} | |
| className='mb-1 text-slate-700' | |
| > | |
| {t('删除账户')} | |
| </Typography.Title> | |
| <Typography.Text type='tertiary' className='text-sm'> | |
| {t('此操作不可逆,所有数据将被永久删除')} | |
| </Typography.Text> | |
| </div> | |
| </div> | |
| <Button | |
| type='danger' | |
| theme='solid' | |
| onClick={() => setShowAccountDeleteModal(true)} | |
| className='w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600' | |
| icon={<IconDelete />} | |
| > | |
| {t('删除账户')} | |
| </Button> | |
| </div> | |
| </Card> | |
| </Space> | |
| </div> | |
| </div> | |
| </TabPane> | |
| </Tabs> | |
| </Card> | |
| ); | |
| }; | |
| export default AccountManagement; | |