Files
new-api/web/src/components/table/users/modals/UserBindingManagementModal.jsx

434 lines
13 KiB
JavaScript

/*
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 { useTranslation } from 'react-i18next';
import {
API,
showError,
showSuccess,
getOAuthProviderIcon,
} from '../../../../helpers';
import {
Modal,
Spin,
Typography,
Card,
Checkbox,
Tag,
Button,
} from '@douyinfe/semi-ui';
import {
IconLink,
IconMail,
IconDelete,
IconGithubLogo,
} from '@douyinfe/semi-icons';
import { SiDiscord, SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
const { Text } = Typography;
const UserBindingManagementModal = ({
visible,
onCancel,
userId,
isMobile,
formApiRef,
}) => {
const { t } = useTranslation();
const [bindingLoading, setBindingLoading] = React.useState(false);
const [showBoundOnly, setShowBoundOnly] = React.useState(true);
const [statusInfo, setStatusInfo] = React.useState({});
const [customOAuthBindings, setCustomOAuthBindings] = React.useState([]);
const [builtInBindings, setBuiltInBindings] = React.useState({});
const [bindingActionLoading, setBindingActionLoading] = React.useState({});
const loadBindingData = React.useCallback(async () => {
if (!userId) return;
setBindingLoading(true);
try {
const [statusRes, customBindingRes, userRes] = await Promise.all([
API.get('/api/status'),
API.get(`/api/user/${userId}/oauth/bindings`),
API.get(`/api/user/${userId}`),
]);
if (statusRes.data?.success) {
setStatusInfo(statusRes.data.data || {});
} else {
showError(statusRes.data?.message || t('操作失败'));
}
if (customBindingRes.data?.success) {
setCustomOAuthBindings(customBindingRes.data.data || []);
} else {
showError(customBindingRes.data?.message || t('操作失败'));
}
if (userRes.data?.success) {
const userData = userRes.data.data || {};
setBuiltInBindings({
email: userData.email || '',
github_id: userData.github_id || '',
discord_id: userData.discord_id || '',
oidc_id: userData.oidc_id || '',
wechat_id: userData.wechat_id || '',
telegram_id: userData.telegram_id || '',
linux_do_id: userData.linux_do_id || '',
});
} else {
showError(userRes.data?.message || t('操作失败'));
}
} catch (error) {
showError(
error.response?.data?.message || error.message || t('操作失败'),
);
} finally {
setBindingLoading(false);
}
}, [t, userId]);
React.useEffect(() => {
if (!visible) return;
setShowBoundOnly(true);
setBindingActionLoading({});
loadBindingData();
}, [visible, loadBindingData]);
const setBindingLoadingState = (key, value) => {
setBindingActionLoading((prev) => ({ ...prev, [key]: value }));
};
const handleUnbindBuiltInAccount = (bindingItem) => {
if (!userId) return;
Modal.confirm({
title: t('确认解绑'),
content: t('确定要解绑 {{name}} 吗?', { name: bindingItem.name }),
okText: t('确认'),
cancelText: t('取消'),
onOk: async () => {
const loadingKey = `builtin-${bindingItem.key}`;
setBindingLoadingState(loadingKey, true);
try {
const res = await API.delete(
`/api/user/${userId}/bindings/${bindingItem.key}`,
);
if (!res.data?.success) {
showError(res.data?.message || t('操作失败'));
return;
}
setBuiltInBindings((prev) => ({
...prev,
[bindingItem.field]: '',
}));
formApiRef.current?.setValue(bindingItem.field, '');
showSuccess(t('解绑成功'));
} catch (error) {
showError(
error.response?.data?.message || error.message || t('操作失败'),
);
} finally {
setBindingLoadingState(loadingKey, false);
}
},
});
};
const handleUnbindCustomOAuthAccount = (provider) => {
if (!userId) return;
Modal.confirm({
title: t('确认解绑'),
content: t('确定要解绑 {{name}} 吗?', { name: provider.name }),
okText: t('确认'),
cancelText: t('取消'),
onOk: async () => {
const loadingKey = `custom-${provider.id}`;
setBindingLoadingState(loadingKey, true);
try {
const res = await API.delete(
`/api/user/${userId}/oauth/bindings/${provider.id}`,
);
if (!res.data?.success) {
showError(res.data?.message || t('操作失败'));
return;
}
setCustomOAuthBindings((prev) =>
prev.filter(
(item) => Number(item.provider_id) !== Number(provider.id),
),
);
showSuccess(t('解绑成功'));
} catch (error) {
showError(
error.response?.data?.message || error.message || t('操作失败'),
);
} finally {
setBindingLoadingState(loadingKey, false);
}
},
});
};
const currentValues = formApiRef.current?.getValues?.() || {};
const getBuiltInBindingValue = (field) =>
builtInBindings[field] || currentValues[field] || '';
const builtInBindingItems = [
{
key: 'email',
field: 'email',
name: t('邮箱'),
enabled: true,
value: getBuiltInBindingValue('email'),
icon: (
<IconMail
size='default'
className='text-slate-600 dark:text-slate-300'
/>
),
},
{
key: 'github',
field: 'github_id',
name: 'GitHub',
enabled: Boolean(statusInfo.github_oauth),
value: getBuiltInBindingValue('github_id'),
icon: (
<IconGithubLogo
size='default'
className='text-slate-600 dark:text-slate-300'
/>
),
},
{
key: 'discord',
field: 'discord_id',
name: 'Discord',
enabled: Boolean(statusInfo.discord_oauth),
value: getBuiltInBindingValue('discord_id'),
icon: (
<SiDiscord size={20} className='text-slate-600 dark:text-slate-300' />
),
},
{
key: 'oidc',
field: 'oidc_id',
name: 'OIDC',
enabled: Boolean(statusInfo.oidc_enabled),
value: getBuiltInBindingValue('oidc_id'),
icon: (
<IconLink
size='default'
className='text-slate-600 dark:text-slate-300'
/>
),
},
{
key: 'wechat',
field: 'wechat_id',
name: t('微信'),
enabled: Boolean(statusInfo.wechat_login),
value: getBuiltInBindingValue('wechat_id'),
icon: (
<SiWechat size={20} className='text-slate-600 dark:text-slate-300' />
),
},
{
key: 'telegram',
field: 'telegram_id',
name: 'Telegram',
enabled: Boolean(statusInfo.telegram_oauth),
value: getBuiltInBindingValue('telegram_id'),
icon: (
<SiTelegram size={20} className='text-slate-600 dark:text-slate-300' />
),
},
{
key: 'linuxdo',
field: 'linux_do_id',
name: 'LinuxDO',
enabled: Boolean(statusInfo.linuxdo_oauth),
value: getBuiltInBindingValue('linux_do_id'),
icon: (
<SiLinux size={20} className='text-slate-600 dark:text-slate-300' />
),
},
];
const customBindingMap = new Map(
customOAuthBindings.map((item) => [Number(item.provider_id), item]),
);
const customProviderMap = new Map(
(statusInfo.custom_oauth_providers || []).map((provider) => [
Number(provider.id),
provider,
]),
);
customOAuthBindings.forEach((binding) => {
if (!customProviderMap.has(Number(binding.provider_id))) {
customProviderMap.set(Number(binding.provider_id), {
id: binding.provider_id,
name: binding.provider_name,
icon: binding.provider_icon,
});
}
});
const customBindingItems = Array.from(customProviderMap.values()).map(
(provider) => {
const binding = customBindingMap.get(Number(provider.id));
return {
key: `custom-${provider.id}`,
providerId: provider.id,
name: provider.name,
enabled: true,
value: binding?.provider_user_id || '',
icon: getOAuthProviderIcon(
provider.icon || binding?.provider_icon || '',
20,
),
};
},
);
const allBindingItems = [
...builtInBindingItems.map((item) => ({ ...item, type: 'builtin' })),
...customBindingItems.map((item) => ({ ...item, type: 'custom' })),
];
const boundCount = allBindingItems.filter((item) =>
Boolean(item.value),
).length;
const visibleBindingItems = showBoundOnly
? allBindingItems.filter((item) => Boolean(item.value))
: allBindingItems;
return (
<Modal
centered
visible={visible}
onCancel={onCancel}
footer={null}
width={isMobile ? '100%' : 760}
title={
<div className='flex items-center'>
<IconLink className='mr-2' />
{t('账户绑定管理')}
</div>
}
>
<Spin spinning={bindingLoading}>
<div className='max-h-[68vh] overflow-y-auto pr-1 pb-2'>
<div className='flex items-center justify-between mb-4 gap-3 flex-wrap'>
<Checkbox
checked={showBoundOnly}
onChange={(e) => setShowBoundOnly(Boolean(e.target.checked))}
>
{t('仅显示已绑定')}
</Checkbox>
<Text type='tertiary'>
{t('已绑定')} {boundCount} / {allBindingItems.length}
</Text>
</div>
{visibleBindingItems.length === 0 ? (
<Card className='!rounded-xl border-dashed'>
<Text type='tertiary'>{t('暂无已绑定项')}</Text>
</Card>
) : (
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
{visibleBindingItems.map((item, index) => {
const isBound = Boolean(item.value);
const loadingKey =
item.type === 'builtin'
? `builtin-${item.key}`
: `custom-${item.providerId}`;
const statusText = isBound
? item.value
: item.enabled
? t('未绑定')
: t('未启用');
const shouldSpanTwoColsOnDesktop =
visibleBindingItems.length % 2 === 1 &&
index === visibleBindingItems.length - 1;
return (
<Card
key={item.key}
className={`!rounded-xl ${shouldSpanTwoColsOnDesktop ? 'lg:col-span-2' : ''}`}
>
<div className='flex items-center justify-between gap-3 min-h-[92px]'>
<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'>
{item.icon}
</div>
<div className='min-w-0 flex-1'>
<div className='font-medium text-gray-900 flex items-center gap-2'>
<span>{item.name}</span>
<Tag size='small' color='white'>
{item.type === 'builtin'
? t('内置')
: t('自定义')}
</Tag>
</div>
<div className='text-sm text-gray-500 truncate'>
{statusText}
</div>
</div>
</div>
<Button
type='danger'
theme='borderless'
icon={<IconDelete />}
size='small'
disabled={!isBound}
loading={Boolean(bindingActionLoading[loadingKey])}
onClick={() => {
if (item.type === 'builtin') {
handleUnbindBuiltInAccount(item);
return;
}
handleUnbindCustomOAuthAccount({
id: item.providerId,
name: item.name,
});
}}
>
{t('解绑')}
</Button>
</div>
</Card>
);
})}
</div>
)}
</div>
</Spin>
</Modal>
);
};
export default UserBindingManagementModal;