Bläddra i källkod

feat: move user bindings to dedicated management modal

Seefs 1 vecka sedan
förälder
incheckning
9a5f8222bd

+ 100 - 21
controller/custom_oauth.go

@@ -38,6 +38,14 @@ type CustomOAuthProviderResponse struct {
 	AccessDeniedMessage   string `json:"access_denied_message"`
 }
 
+type UserOAuthBindingResponse struct {
+	ProviderId     int    `json:"provider_id"`
+	ProviderName   string `json:"provider_name"`
+	ProviderSlug   string `json:"provider_slug"`
+	ProviderIcon   string `json:"provider_icon"`
+	ProviderUserId string `json:"provider_user_id"`
+}
+
 func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthProviderResponse {
 	return &CustomOAuthProviderResponse{
 		Id:                    p.Id,
@@ -433,6 +441,30 @@ func DeleteCustomOAuthProvider(c *gin.Context) {
 	})
 }
 
+func buildUserOAuthBindingsResponse(userId int) ([]UserOAuthBindingResponse, error) {
+	bindings, err := model.GetUserOAuthBindingsByUserId(userId)
+	if err != nil {
+		return nil, err
+	}
+
+	response := make([]UserOAuthBindingResponse, 0, len(bindings))
+	for _, binding := range bindings {
+		provider, err := model.GetCustomOAuthProviderById(binding.ProviderId)
+		if err != nil {
+			continue
+		}
+		response = append(response, UserOAuthBindingResponse{
+			ProviderId:     binding.ProviderId,
+			ProviderName:   provider.Name,
+			ProviderSlug:   provider.Slug,
+			ProviderIcon:   provider.Icon,
+			ProviderUserId: binding.ProviderUserId,
+		})
+	}
+
+	return response, nil
+}
+
 // GetUserOAuthBindings returns all OAuth bindings for the current user
 func GetUserOAuthBindings(c *gin.Context) {
 	userId := c.GetInt("id")
@@ -441,34 +473,43 @@ func GetUserOAuthBindings(c *gin.Context) {
 		return
 	}
 
-	bindings, err := model.GetUserOAuthBindingsByUserId(userId)
+	response, err := buildUserOAuthBindingsResponse(userId)
 	if err != nil {
 		common.ApiError(c, err)
 		return
 	}
 
-	// Build response with provider info
-	type BindingResponse struct {
-		ProviderId     int    `json:"provider_id"`
-		ProviderName   string `json:"provider_name"`
-		ProviderSlug   string `json:"provider_slug"`
-		ProviderIcon   string `json:"provider_icon"`
-		ProviderUserId string `json:"provider_user_id"`
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    response,
+	})
+}
+
+func GetUserOAuthBindingsByAdmin(c *gin.Context) {
+	userIdStr := c.Param("id")
+	userId, err := strconv.Atoi(userIdStr)
+	if err != nil {
+		common.ApiErrorMsg(c, "invalid user id")
+		return
 	}
 
-	response := make([]BindingResponse, 0)
-	for _, binding := range bindings {
-		provider, err := model.GetCustomOAuthProviderById(binding.ProviderId)
-		if err != nil {
-			continue // Skip if provider not found
-		}
-		response = append(response, BindingResponse{
-			ProviderId:     binding.ProviderId,
-			ProviderName:   provider.Name,
-			ProviderSlug:   provider.Slug,
-			ProviderIcon:   provider.Icon,
-			ProviderUserId: binding.ProviderUserId,
-		})
+	targetUser, err := model.GetUserById(userId, false)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	myRole := c.GetInt("role")
+	if myRole <= targetUser.Role && myRole != common.RoleRootUser {
+		common.ApiErrorMsg(c, "no permission")
+		return
+	}
+
+	response, err := buildUserOAuthBindingsResponse(userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
 	}
 
 	c.JSON(http.StatusOK, gin.H{
@@ -503,3 +544,41 @@ func UnbindCustomOAuth(c *gin.Context) {
 		"message": "解绑成功",
 	})
 }
+
+func UnbindCustomOAuthByAdmin(c *gin.Context) {
+	userIdStr := c.Param("id")
+	userId, err := strconv.Atoi(userIdStr)
+	if err != nil {
+		common.ApiErrorMsg(c, "invalid user id")
+		return
+	}
+
+	targetUser, err := model.GetUserById(userId, false)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	myRole := c.GetInt("role")
+	if myRole <= targetUser.Role && myRole != common.RoleRootUser {
+		common.ApiErrorMsg(c, "no permission")
+		return
+	}
+
+	providerIdStr := c.Param("provider_id")
+	providerId, err := strconv.Atoi(providerIdStr)
+	if err != nil {
+		common.ApiErrorMsg(c, "invalid provider id")
+		return
+	}
+
+	if err := model.DeleteUserOAuthBinding(userId, providerId); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "success",
+	})
+}

+ 38 - 0
controller/user.go

@@ -582,6 +582,44 @@ func UpdateUser(c *gin.Context) {
 	return
 }
 
+func AdminClearUserBinding(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
+		return
+	}
+
+	bindingType := strings.ToLower(strings.TrimSpace(c.Param("binding_type")))
+	if bindingType == "" {
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
+		return
+	}
+
+	user, err := model.GetUserById(id, false)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	myRole := c.GetInt("role")
+	if myRole <= user.Role && myRole != common.RoleRootUser {
+		common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
+		return
+	}
+
+	if err := user.ClearBinding(bindingType); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	model.RecordLog(user.Id, model.LogTypeManage, fmt.Sprintf("admin cleared %s binding for user %s", bindingType, user.Username))
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "success",
+	})
+}
+
 func UpdateSelf(c *gin.Context) {
 	var requestData map[string]interface{}
 	err := json.NewDecoder(c.Request.Body).Decode(&requestData)

+ 31 - 0
model/user.go

@@ -536,6 +536,37 @@ func (user *User) Edit(updatePassword bool) error {
 	return updateUserCache(*user)
 }
 
+func (user *User) ClearBinding(bindingType string) error {
+	if user.Id == 0 {
+		return errors.New("user id is empty")
+	}
+
+	bindingColumnMap := map[string]string{
+		"email":    "email",
+		"github":   "github_id",
+		"discord":  "discord_id",
+		"oidc":     "oidc_id",
+		"wechat":   "wechat_id",
+		"telegram": "telegram_id",
+		"linuxdo":  "linux_do_id",
+	}
+
+	column, ok := bindingColumnMap[bindingType]
+	if !ok {
+		return errors.New("invalid binding type")
+	}
+
+	if err := DB.Model(&User{}).Where("id = ?", user.Id).Update(column, "").Error; err != nil {
+		return err
+	}
+
+	if err := DB.Where("id = ?", user.Id).First(user).Error; err != nil {
+		return err
+	}
+
+	return updateUserCache(*user)
+}
+
 func (user *User) Delete() error {
 	if user.Id == 0 {
 		return errors.New("id 为空!")

+ 3 - 0
router/api-router.go

@@ -114,6 +114,9 @@ func SetApiRouter(router *gin.Engine) {
 				adminRoute.GET("/topup", controller.GetAllTopUps)
 				adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp)
 				adminRoute.GET("/search", controller.SearchUsers)
+				adminRoute.GET("/:id/oauth/bindings", controller.GetUserOAuthBindingsByAdmin)
+				adminRoute.DELETE("/:id/oauth/bindings/:provider_id", controller.UnbindCustomOAuthByAdmin)
+				adminRoute.DELETE("/:id/bindings/:binding_type", controller.AdminClearUserBinding)
 				adminRoute.GET("/:id", controller.GetUser)
 				adminRoute.POST("/", controller.CreateUser)
 				adminRoute.POST("/manage", controller.ManageUser)

+ 61 - 46
web/src/components/table/users/modals/EditUserModal.jsx

@@ -45,7 +45,6 @@ import {
   Avatar,
   Row,
   Col,
-  Input,
   InputNumber,
 } from '@douyinfe/semi-ui';
 import {
@@ -56,6 +55,7 @@ import {
   IconUserGroup,
   IconPlus,
 } from '@douyinfe/semi-icons';
+import UserBindingManagementModal from './UserBindingManagementModal';
 
 const { Text, Title } = Typography;
 
@@ -68,6 +68,7 @@ const EditUserModal = (props) => {
   const [addAmountLocal, setAddAmountLocal] = useState('');
   const isMobile = useIsMobile();
   const [groupOptions, setGroupOptions] = useState([]);
+  const [bindingModalVisible, setBindingModalVisible] = useState(false);
   const formApiRef = useRef(null);
 
   const isEdit = Boolean(userId);
@@ -81,6 +82,7 @@ const EditUserModal = (props) => {
     discord_id: '',
     wechat_id: '',
     telegram_id: '',
+    linux_do_id: '',
     email: '',
     quota: 0,
     group: 'default',
@@ -115,8 +117,17 @@ const EditUserModal = (props) => {
   useEffect(() => {
     loadUser();
     if (userId) fetchGroups();
+    setBindingModalVisible(false);
   }, [props.editingUser.id]);
 
+  const openBindingModal = () => {
+    setBindingModalVisible(true);
+  };
+
+  const closeBindingModal = () => {
+    setBindingModalVisible(false);
+  };
+
   /* ----------------------- submit ----------------------- */
   const submit = async (values) => {
     setLoading(true);
@@ -316,56 +327,51 @@ const EditUserModal = (props) => {
                   </Card>
                 )}
 
-                {/* 绑定信息 */}
-                <Card className='!rounded-2xl shadow-sm border-0'>
-                  <div className='flex items-center mb-2'>
-                    <Avatar
-                      size='small'
-                      color='purple'
-                      className='mr-2 shadow-md'
-                    >
-                      <IconLink size={16} />
-                    </Avatar>
-                    <div>
-                      <Text className='text-lg font-medium'>
-                        {t('绑定信息')}
-                      </Text>
-                      <div className='text-xs text-gray-600'>
-                        {t('第三方账户绑定状态(只读)')}
+                {/* 绑定信息入口 */}
+                {userId && (
+                  <Card className='!rounded-2xl shadow-sm border-0'>
+                    <div className='flex items-center justify-between gap-3'>
+                      <div className='flex items-center min-w-0'>
+                        <Avatar
+                          size='small'
+                          color='purple'
+                          className='mr-2 shadow-md'
+                        >
+                          <IconLink size={16} />
+                        </Avatar>
+                        <div className='min-w-0'>
+                          <Text className='text-lg font-medium'>
+                            {t('绑定信息')}
+                          </Text>
+                          <div className='text-xs text-gray-600'>
+                            {t('第三方账户绑定状态(只读)')}
+                          </div>
+                        </div>
                       </div>
+                      <Button
+                        type='primary'
+                        theme='outline'
+                        onClick={openBindingModal}
+                      >
+                        {t('修改绑定')}
+                      </Button>
                     </div>
-                  </div>
-
-                  <Row gutter={12}>
-                    {[
-                      'github_id',
-                      'discord_id',
-                      'oidc_id',
-                      'wechat_id',
-                      'email',
-                      'telegram_id',
-                    ].map((field) => (
-                      <Col span={24} key={field}>
-                        <Form.Input
-                          field={field}
-                          label={t(
-                            `已绑定的 ${field.replace('_id', '').toUpperCase()} 账户`,
-                          )}
-                          readonly
-                          placeholder={t(
-                            '此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
-                          )}
-                        />
-                      </Col>
-                    ))}
-                  </Row>
-                </Card>
+                  </Card>
+                )}
               </div>
             )}
           </Form>
         </Spin>
       </SideSheet>
 
+      <UserBindingManagementModal
+        visible={bindingModalVisible}
+        onCancel={closeBindingModal}
+        userId={userId}
+        isMobile={isMobile}
+        formApiRef={formApiRef}
+      />
+
       {/* 添加额度模态框 */}
       <Modal
         centered
@@ -401,7 +407,10 @@ const EditUserModal = (props) => {
           <div className='mb-3'>
             <div className='mb-1'>
               <Text size='small'>{t('金额')}</Text>
-              <Text size='small' type='tertiary'> ({t('仅用于换算,实际保存的是额度')})</Text>
+              <Text size='small' type='tertiary'>
+                {' '}
+                ({t('仅用于换算,实际保存的是额度')})
+              </Text>
             </div>
             <InputNumber
               prefix={getCurrencyConfig().symbol}
@@ -411,7 +420,9 @@ const EditUserModal = (props) => {
               onChange={(val) => {
                 setAddAmountLocal(val);
                 setAddQuotaLocal(
-                  val != null && val !== '' ? displayAmountToQuota(Math.abs(val)) * Math.sign(val) : '',
+                  val != null && val !== ''
+                    ? displayAmountToQuota(Math.abs(val)) * Math.sign(val)
+                    : '',
                 );
               }}
               style={{ width: '100%' }}
@@ -430,7 +441,11 @@ const EditUserModal = (props) => {
               setAddQuotaLocal(val);
               setAddAmountLocal(
                 val != null && val !== ''
-                  ? Number((quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)).toFixed(2))
+                  ? Number(
+                      (
+                        quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)
+                      ).toFixed(2),
+                    )
                   : '',
               );
             }}

+ 396 - 0
web/src/components/table/users/modals/UserBindingManagementModal.jsx

@@ -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;