|
@@ -0,0 +1,396 @@
|
|
|
|
|
+/*
|
|
|
|
|
+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 [showUnboundOnly, setShowUnboundOnly] = React.useState(false);
|
|
|
|
|
+ const [statusInfo, setStatusInfo] = React.useState({});
|
|
|
|
|
+ const [customOAuthBindings, setCustomOAuthBindings] = React.useState([]);
|
|
|
|
|
+ const [bindingActionLoading, setBindingActionLoading] = React.useState({});
|
|
|
|
|
+
|
|
|
|
|
+ const loadBindingData = React.useCallback(async () => {
|
|
|
|
|
+ if (!userId) return;
|
|
|
|
|
+
|
|
|
|
|
+ setBindingLoading(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const [statusRes, customBindingRes] = await Promise.all([
|
|
|
|
|
+ API.get('/api/status'),
|
|
|
|
|
+ API.get(`/api/user/${userId}/oauth/bindings`),
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ 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('操作失败'));
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ showError(
|
|
|
|
|
+ error.response?.data?.message || error.message || t('操作失败'),
|
|
|
|
|
+ );
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setBindingLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [t, userId]);
|
|
|
|
|
+
|
|
|
|
|
+ React.useEffect(() => {
|
|
|
|
|
+ if (!visible) return;
|
|
|
|
|
+ setShowUnboundOnly(false);
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+ 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 builtInBindingItems = [
|
|
|
|
|
+ {
|
|
|
|
|
+ key: 'email',
|
|
|
|
|
+ field: 'email',
|
|
|
|
|
+ name: t('邮箱'),
|
|
|
|
|
+ enabled: true,
|
|
|
|
|
+ value: currentValues.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: currentValues.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: currentValues.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: currentValues.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: currentValues.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: currentValues.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: currentValues.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 visibleBindingItems = showUnboundOnly
|
|
|
|
|
+ ? allBindingItems.filter((item) => !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='flex items-center justify-between mb-4 gap-3 flex-wrap'>
|
|
|
|
|
+ <Checkbox
|
|
|
|
|
+ checked={showUnboundOnly}
|
|
|
|
|
+ onChange={(e) => setShowUnboundOnly(Boolean(e.target.checked))}
|
|
|
|
|
+ >
|
|
|
|
|
+ {`${t('筛选')} ${t('未绑定')}`}
|
|
|
|
|
+ </Checkbox>
|
|
|
|
|
+ <Text type='tertiary'>
|
|
|
|
|
+ {t('筛选')} · {visibleBindingItems.length}
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {visibleBindingItems.length === 0 ? (
|
|
|
|
|
+ <Card className='!rounded-xl border-dashed'>
|
|
|
|
|
+ <Text type='tertiary'>{t('暂无自定义 OAuth 提供商')}</Text>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className='grid grid-cols-1 lg:grid-cols-2 gap-3'>
|
|
|
|
|
+ {visibleBindingItems.map((item) => {
|
|
|
|
|
+ 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('未启用');
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Card key={item.key} 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'>
|
|
|
|
|
+ {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' ? 'Built-in' : 'Custom'}
|
|
|
|
|
+ </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>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Spin>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export default UserBindingManagementModal;
|