| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583 |
- /*
- 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='primary'
- theme={passkeyEnabled ? 'outline' : 'solid'}
- onClick={passkeyEnabled ? onPasskeyDelete : onPasskeyRegister}
- className='w-full sm:w-auto'
- 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;
|