feat(oauth): implement custom OAuth provider management #1106

- Add support for custom OAuth providers, including creation, retrieval, updating, and deletion.
- Introduce new model and controller for managing custom OAuth providers.
- Enhance existing OAuth logic to accommodate custom providers.
- Update API routes for custom OAuth provider management.
- Include i18n support for custom OAuth-related messages.
This commit is contained in:
CaIon
2026-02-05 21:18:43 +08:00
parent 632baadb57
commit af54ea85d2
20 changed files with 2066 additions and 11 deletions

View File

@@ -42,10 +42,14 @@ import { SiTelegram, SiWechat, SiLinux, SiDiscord } from 'react-icons/si';
import { UserPlus, ShieldCheck } from 'lucide-react';
import TelegramLoginButton from 'react-telegram-login';
import {
API,
showError,
showSuccess,
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked,
onDiscordOAuthClicked,
onCustomOAuthClicked,
} from '../../../../helpers';
import TwoFASetting from '../components/TwoFASetting';
@@ -94,6 +98,66 @@ const AccountManagement = ({
const isBound = (accountId) => Boolean(accountId);
const [showTelegramBindModal, setShowTelegramBindModal] =
React.useState(false);
const [customOAuthBindings, setCustomOAuthBindings] = React.useState([]);
const [customOAuthLoading, setCustomOAuthLoading] = React.useState({});
// Fetch custom OAuth bindings
const loadCustomOAuthBindings = async () => {
try {
const res = await API.get('/api/user/oauth/bindings');
if (res.data.success) {
setCustomOAuthBindings(res.data.data || []);
}
} catch (error) {
// ignore
}
};
// Unbind custom OAuth provider
const handleUnbindCustomOAuth = async (providerId, providerName) => {
Modal.confirm({
title: t('确认解绑'),
content: t('确定要解绑 {{name}} 吗?', { name: providerName }),
okText: t('确认'),
cancelText: t('取消'),
onOk: async () => {
setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: true }));
try {
const res = await API.delete(`/api/user/oauth/bindings/${providerId}`);
if (res.data.success) {
showSuccess(t('解绑成功'));
await loadCustomOAuthBindings();
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('操作失败'));
} finally {
setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: false }));
}
},
});
};
// Handle bind custom OAuth
const handleBindCustomOAuth = (provider) => {
onCustomOAuthClicked(provider);
};
// Check if custom OAuth provider is bound
const isCustomOAuthBound = (providerId) => {
return customOAuthBindings.some((b) => b.provider_id === providerId);
};
// Get binding info for a provider
const getCustomOAuthBinding = (providerId) => {
return customOAuthBindings.find((b) => b.provider_id === providerId);
};
React.useEffect(() => {
loadCustomOAuthBindings();
}, []);
const passkeyEnabled = passkeyStatus?.enabled;
const lastUsedLabel = passkeyStatus?.last_used_at
? new Date(passkeyStatus.last_used_at).toLocaleString()
@@ -447,6 +511,64 @@ const AccountManagement = ({
</div>
</div>
</Card>
{/* 自定义 OAuth 提供商绑定 */}
{status.custom_oauth_providers &&
status.custom_oauth_providers.map((provider) => {
const bound = isCustomOAuthBound(provider.id);
const binding = getCustomOAuthBinding(provider.id);
return (
<Card key={provider.slug} 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'>
<IconLock
size='default'
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{provider.name}
</div>
<div className='text-sm text-gray-500 truncate'>
{bound
? renderAccountInfo(
binding?.provider_user_id,
t('{{name}} ID', { name: provider.name }),
)
: t('未绑定')}
</div>
</div>
</div>
<div className='flex-shrink-0'>
{bound ? (
<Button
type='danger'
theme='outline'
size='small'
loading={customOAuthLoading[provider.id]}
onClick={() =>
handleUnbindCustomOAuth(provider.id, provider.name)
}
>
{t('解绑')}
</Button>
) : (
<Button
type='primary'
theme='outline'
size='small'
onClick={() => handleBindCustomOAuth(provider)}
>
{t('绑定')}
</Button>
)}
</div>
</div>
</Card>
);
})}
</div>
</div>
</TabPane>