From 9a5f8222bd8acaf7052c374d7153b9320fe7c1cc Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 23 Feb 2026 14:51:55 +0800 Subject: [PATCH] feat: move user bindings to dedicated management modal --- controller/custom_oauth.go | 121 +++++- controller/user.go | 38 ++ model/user.go | 31 ++ router/api-router.go | 3 + .../table/users/modals/EditUserModal.jsx | 107 +++-- .../modals/UserBindingManagementModal.jsx | 396 ++++++++++++++++++ 6 files changed, 629 insertions(+), 67 deletions(-) create mode 100644 web/src/components/table/users/modals/UserBindingManagementModal.jsx diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go index 3197a9163..c21ec7910 100644 --- a/controller/custom_oauth.go +++ b/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", + }) +} diff --git a/controller/user.go b/controller/user.go index db078071e..b58eab88f 100644 --- a/controller/user.go +++ b/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) diff --git a/model/user.go b/model/user.go index e0c9c686f..e0f803a90 100644 --- a/model/user.go +++ b/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 为空!") diff --git a/router/api-router.go b/router/api-router.go index d60ba39b2..b6e418c6e 100644 --- a/router/api-router.go +++ b/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) diff --git a/web/src/components/table/users/modals/EditUserModal.jsx b/web/src/components/table/users/modals/EditUserModal.jsx index 32601daa8..297f18116 100644 --- a/web/src/components/table/users/modals/EditUserModal.jsx +++ b/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) => { )} - {/* 绑定信息 */} - -
- - - -
- - {t('绑定信息')} - -
- {t('第三方账户绑定状态(只读)')} + {/* 绑定信息入口 */} + {userId && ( + +
+
+ + + +
+ + {t('绑定信息')} + +
+ {t('第三方账户绑定状态(只读)')} +
+
+
-
- - - {[ - 'github_id', - 'discord_id', - 'oidc_id', - 'wechat_id', - 'email', - 'telegram_id', - ].map((field) => ( - - - - ))} - - + + )}
)} + + {/* 添加额度模态框 */} {
{t('金额')} - ({t('仅用于换算,实际保存的是额度')}) + + {' '} + ({t('仅用于换算,实际保存的是额度')}) +
{ 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), + ) : '', ); }} diff --git a/web/src/components/table/users/modals/UserBindingManagementModal.jsx b/web/src/components/table/users/modals/UserBindingManagementModal.jsx new file mode 100644 index 000000000..547c04f7d --- /dev/null +++ b/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 . + +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: ( + + ), + }, + { + key: 'github', + field: 'github_id', + name: 'GitHub', + enabled: Boolean(statusInfo.github_oauth), + value: currentValues.github_id, + icon: ( + + ), + }, + { + key: 'discord', + field: 'discord_id', + name: 'Discord', + enabled: Boolean(statusInfo.discord_oauth), + value: currentValues.discord_id, + icon: ( + + ), + }, + { + key: 'oidc', + field: 'oidc_id', + name: 'OIDC', + enabled: Boolean(statusInfo.oidc_enabled), + value: currentValues.oidc_id, + icon: ( + + ), + }, + { + key: 'wechat', + field: 'wechat_id', + name: t('微信'), + enabled: Boolean(statusInfo.wechat_login), + value: currentValues.wechat_id, + icon: ( + + ), + }, + { + key: 'telegram', + field: 'telegram_id', + name: 'Telegram', + enabled: Boolean(statusInfo.telegram_oauth), + value: currentValues.telegram_id, + icon: ( + + ), + }, + { + key: 'linuxdo', + field: 'linux_do_id', + name: 'LinuxDO', + enabled: Boolean(statusInfo.linuxdo_oauth), + value: currentValues.linux_do_id, + icon: ( + + ), + }, + ]; + + 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 ( + + + {t('绑定信息')} +
+ } + > + +
+ setShowUnboundOnly(Boolean(e.target.checked))} + > + {`${t('筛选')} ${t('未绑定')}`} + + + {t('筛选')} · {visibleBindingItems.length} + +
+ + {visibleBindingItems.length === 0 ? ( + + {t('暂无自定义 OAuth 提供商')} + + ) : ( +
+ {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 ( + +
+
+
+ {item.icon} +
+
+
+ {item.name} + + {item.type === 'builtin' ? 'Built-in' : 'Custom'} + +
+
+ {statusText} +
+
+
+ +
+
+ ); + })} +
+ )} +
+
+ ); +}; + +export default UserBindingManagementModal;