mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 02:42:29 +00:00
feat: move user bindings to dedicated management modal
This commit is contained in:
@@ -38,6 +38,14 @@ type CustomOAuthProviderResponse struct {
|
|||||||
AccessDeniedMessage string `json:"access_denied_message"`
|
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 {
|
func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthProviderResponse {
|
||||||
return &CustomOAuthProviderResponse{
|
return &CustomOAuthProviderResponse{
|
||||||
Id: p.Id,
|
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
|
// GetUserOAuthBindings returns all OAuth bindings for the current user
|
||||||
func GetUserOAuthBindings(c *gin.Context) {
|
func GetUserOAuthBindings(c *gin.Context) {
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
@@ -441,34 +473,43 @@ func GetUserOAuthBindings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bindings, err := model.GetUserOAuthBindingsByUserId(userId)
|
response, err := buildUserOAuthBindingsResponse(userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response with provider info
|
c.JSON(http.StatusOK, gin.H{
|
||||||
type BindingResponse struct {
|
"success": true,
|
||||||
ProviderId int `json:"provider_id"`
|
"message": "",
|
||||||
ProviderName string `json:"provider_name"`
|
"data": response,
|
||||||
ProviderSlug string `json:"provider_slug"`
|
})
|
||||||
ProviderIcon string `json:"provider_icon"`
|
}
|
||||||
ProviderUserId string `json:"provider_user_id"`
|
|
||||||
|
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)
|
targetUser, err := model.GetUserById(userId, false)
|
||||||
for _, binding := range bindings {
|
if err != nil {
|
||||||
provider, err := model.GetCustomOAuthProviderById(binding.ProviderId)
|
common.ApiError(c, err)
|
||||||
if err != nil {
|
return
|
||||||
continue // Skip if provider not found
|
}
|
||||||
}
|
|
||||||
response = append(response, BindingResponse{
|
myRole := c.GetInt("role")
|
||||||
ProviderId: binding.ProviderId,
|
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||||
ProviderName: provider.Name,
|
common.ApiErrorMsg(c, "no permission")
|
||||||
ProviderSlug: provider.Slug,
|
return
|
||||||
ProviderIcon: provider.Icon,
|
}
|
||||||
ProviderUserId: binding.ProviderUserId,
|
|
||||||
})
|
response, err := buildUserOAuthBindingsResponse(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -503,3 +544,41 @@ func UnbindCustomOAuth(c *gin.Context) {
|
|||||||
"message": "解绑成功",
|
"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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -582,6 +582,44 @@ func UpdateUser(c *gin.Context) {
|
|||||||
return
|
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) {
|
func UpdateSelf(c *gin.Context) {
|
||||||
var requestData map[string]interface{}
|
var requestData map[string]interface{}
|
||||||
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
|
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
|
||||||
|
|||||||
@@ -536,6 +536,37 @@ func (user *User) Edit(updatePassword bool) error {
|
|||||||
return updateUserCache(*user)
|
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 {
|
func (user *User) Delete() error {
|
||||||
if user.Id == 0 {
|
if user.Id == 0 {
|
||||||
return errors.New("id 为空!")
|
return errors.New("id 为空!")
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
adminRoute.GET("/topup", controller.GetAllTopUps)
|
adminRoute.GET("/topup", controller.GetAllTopUps)
|
||||||
adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp)
|
adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp)
|
||||||
adminRoute.GET("/search", controller.SearchUsers)
|
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.GET("/:id", controller.GetUser)
|
||||||
adminRoute.POST("/", controller.CreateUser)
|
adminRoute.POST("/", controller.CreateUser)
|
||||||
adminRoute.POST("/manage", controller.ManageUser)
|
adminRoute.POST("/manage", controller.ManageUser)
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Input,
|
|
||||||
InputNumber,
|
InputNumber,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +55,7 @@ import {
|
|||||||
IconUserGroup,
|
IconUserGroup,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
|
import UserBindingManagementModal from './UserBindingManagementModal';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
@@ -68,6 +68,7 @@ const EditUserModal = (props) => {
|
|||||||
const [addAmountLocal, setAddAmountLocal] = useState('');
|
const [addAmountLocal, setAddAmountLocal] = useState('');
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [groupOptions, setGroupOptions] = useState([]);
|
const [groupOptions, setGroupOptions] = useState([]);
|
||||||
|
const [bindingModalVisible, setBindingModalVisible] = useState(false);
|
||||||
const formApiRef = useRef(null);
|
const formApiRef = useRef(null);
|
||||||
|
|
||||||
const isEdit = Boolean(userId);
|
const isEdit = Boolean(userId);
|
||||||
@@ -81,6 +82,7 @@ const EditUserModal = (props) => {
|
|||||||
discord_id: '',
|
discord_id: '',
|
||||||
wechat_id: '',
|
wechat_id: '',
|
||||||
telegram_id: '',
|
telegram_id: '',
|
||||||
|
linux_do_id: '',
|
||||||
email: '',
|
email: '',
|
||||||
quota: 0,
|
quota: 0,
|
||||||
group: 'default',
|
group: 'default',
|
||||||
@@ -115,8 +117,17 @@ const EditUserModal = (props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUser();
|
loadUser();
|
||||||
if (userId) fetchGroups();
|
if (userId) fetchGroups();
|
||||||
|
setBindingModalVisible(false);
|
||||||
}, [props.editingUser.id]);
|
}, [props.editingUser.id]);
|
||||||
|
|
||||||
|
const openBindingModal = () => {
|
||||||
|
setBindingModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeBindingModal = () => {
|
||||||
|
setBindingModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
/* ----------------------- submit ----------------------- */
|
/* ----------------------- submit ----------------------- */
|
||||||
const submit = async (values) => {
|
const submit = async (values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -316,56 +327,51 @@ const EditUserModal = (props) => {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 绑定信息 */}
|
{/* 绑定信息入口 */}
|
||||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
{userId && (
|
||||||
<div className='flex items-center mb-2'>
|
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||||
<Avatar
|
<div className='flex items-center justify-between gap-3'>
|
||||||
size='small'
|
<div className='flex items-center min-w-0'>
|
||||||
color='purple'
|
<Avatar
|
||||||
className='mr-2 shadow-md'
|
size='small'
|
||||||
>
|
color='purple'
|
||||||
<IconLink size={16} />
|
className='mr-2 shadow-md'
|
||||||
</Avatar>
|
>
|
||||||
<div>
|
<IconLink size={16} />
|
||||||
<Text className='text-lg font-medium'>
|
</Avatar>
|
||||||
{t('绑定信息')}
|
<div className='min-w-0'>
|
||||||
</Text>
|
<Text className='text-lg font-medium'>
|
||||||
<div className='text-xs text-gray-600'>
|
{t('绑定信息')}
|
||||||
{t('第三方账户绑定状态(只读)')}
|
</Text>
|
||||||
|
<div className='text-xs text-gray-600'>
|
||||||
|
{t('第三方账户绑定状态(只读)')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
theme='outline'
|
||||||
|
onClick={openBindingModal}
|
||||||
|
>
|
||||||
|
{t('修改绑定')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
)}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Spin>
|
</Spin>
|
||||||
</SideSheet>
|
</SideSheet>
|
||||||
|
|
||||||
|
<UserBindingManagementModal
|
||||||
|
visible={bindingModalVisible}
|
||||||
|
onCancel={closeBindingModal}
|
||||||
|
userId={userId}
|
||||||
|
isMobile={isMobile}
|
||||||
|
formApiRef={formApiRef}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 添加额度模态框 */}
|
{/* 添加额度模态框 */}
|
||||||
<Modal
|
<Modal
|
||||||
centered
|
centered
|
||||||
@@ -401,7 +407,10 @@ const EditUserModal = (props) => {
|
|||||||
<div className='mb-3'>
|
<div className='mb-3'>
|
||||||
<div className='mb-1'>
|
<div className='mb-1'>
|
||||||
<Text size='small'>{t('金额')}</Text>
|
<Text size='small'>{t('金额')}</Text>
|
||||||
<Text size='small' type='tertiary'> ({t('仅用于换算,实际保存的是额度')})</Text>
|
<Text size='small' type='tertiary'>
|
||||||
|
{' '}
|
||||||
|
({t('仅用于换算,实际保存的是额度')})
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
prefix={getCurrencyConfig().symbol}
|
prefix={getCurrencyConfig().symbol}
|
||||||
@@ -411,7 +420,9 @@ const EditUserModal = (props) => {
|
|||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
setAddAmountLocal(val);
|
setAddAmountLocal(val);
|
||||||
setAddQuotaLocal(
|
setAddQuotaLocal(
|
||||||
val != null && val !== '' ? displayAmountToQuota(Math.abs(val)) * Math.sign(val) : '',
|
val != null && val !== ''
|
||||||
|
? displayAmountToQuota(Math.abs(val)) * Math.sign(val)
|
||||||
|
: '',
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -430,7 +441,11 @@ const EditUserModal = (props) => {
|
|||||||
setAddQuotaLocal(val);
|
setAddQuotaLocal(val);
|
||||||
setAddAmountLocal(
|
setAddAmountLocal(
|
||||||
val != null && val !== ''
|
val != null && val !== ''
|
||||||
? Number((quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)).toFixed(2))
|
? Number(
|
||||||
|
(
|
||||||
|
quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)
|
||||||
|
).toFixed(2),
|
||||||
|
)
|
||||||
: '',
|
: '',
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user