diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go
index 3197a916..c21ec791 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 db078071..b58eab88 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 e0c9c686..e0f803a9 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 d60ba39b..b6e418c6 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 32601daa..90676d84 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);
@@ -196,7 +207,7 @@ const EditUserModal = (props) => {
onSubmit={submit}
>
{({ values }) => (
-
+
{/* 基本信息 */}
@@ -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 00000000..c5b2a3a1
--- /dev/null
+++ b/web/src/components/table/users/modals/UserBindingManagementModal.jsx
@@ -0,0 +1,410 @@
+/*
+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 [showBoundOnly, setShowBoundOnly] = React.useState(true);
+ 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;
+ 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;
+ }
+ 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 boundCount = allBindingItems.filter((item) =>
+ Boolean(item.value),
+ ).length;
+
+ const visibleBindingItems = showBoundOnly
+ ? allBindingItems.filter((item) => Boolean(item.value))
+ : allBindingItems;
+
+ return (
+
+
+ {t('账户绑定管理')}
+
+ }
+ >
+
+
+
+ setShowBoundOnly(Boolean(e.target.checked))}
+ >
+ {t('仅显示已绑定')}
+
+
+ {t('已绑定')} {boundCount} / {allBindingItems.length}
+
+
+
+ {visibleBindingItems.length === 0 ? (
+
+ {t('暂无已绑定项')}
+
+ ) : (
+
+ {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 (
+
+
+
+
+ {item.icon}
+
+
+
+ {item.name}
+
+ {item.type === 'builtin'
+ ? t('内置')
+ : t('自定义')}
+
+
+
+ {statusText}
+
+
+
+
}
+ size='small'
+ disabled={!isBound}
+ loading={Boolean(bindingActionLoading[loadingKey])}
+ onClick={() => {
+ if (item.type === 'builtin') {
+ handleUnbindBuiltInAccount(item);
+ return;
+ }
+ handleUnbindCustomOAuthAccount({
+ id: item.providerId,
+ name: item.name,
+ });
+ }}
+ >
+ {t('解绑')}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+};
+
+export default UserBindingManagementModal;