mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-29 23:28:36 +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"`
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 为空!")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
: '',
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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