| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396 |
- /*
- 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;
|