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

@@ -34,6 +34,7 @@ import {
onDiscordOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked,
onCustomOAuthClicked,
prepareCredentialRequestOptions,
buildAssertionResult,
isPasskeySupported,
@@ -109,6 +110,7 @@ const LoginForm = () => {
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
const githubTimeoutRef = useRef(null);
const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
const logo = getLogo();
const systemName = getSystemName();
@@ -373,6 +375,23 @@ const LoginForm = () => {
}
};
// 包装的自定义OAuth登录点击处理
const handleCustomOAuthClick = (provider) => {
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
try {
onCustomOAuthClicked(provider, { shouldLogout: true });
} finally {
// 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => {
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false }));
}, 3000);
}
};
// 包装的邮箱登录选项点击处理
const handleEmailLoginClick = () => {
setEmailLoginLoading(true);
@@ -572,6 +591,23 @@ const LoginForm = () => {
</Button>
)}
{status.custom_oauth_providers &&
status.custom_oauth_providers.map((provider) => (
<Button
key={provider.slug}
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<IconLock size='large' />}
onClick={() => handleCustomOAuthClick(provider)}
loading={customOAuthLoading[provider.slug]}
>
<span className='ml-3'>
{t('使用 {{name}} 继续', { name: provider.name })}
</span>
</Button>
))}
{status.telegram_oauth && (
<div className='flex justify-center my-2'>
<TelegramLoginButton

View File

@@ -0,0 +1,631 @@
/*
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, { useEffect, useState } from 'react';
import {
Button,
Form,
Row,
Col,
Typography,
Modal,
Banner,
Card,
Table,
Tag,
Popconfirm,
Space,
Select,
} from '@douyinfe/semi-ui';
import { IconPlus, IconEdit, IconDelete } from '@douyinfe/semi-icons';
import { API, showError, showSuccess } from '../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
// Preset templates for common OAuth providers
const OAUTH_PRESETS = {
'github-enterprise': {
name: 'GitHub Enterprise',
authorization_endpoint: '/login/oauth/authorize',
token_endpoint: '/login/oauth/access_token',
user_info_endpoint: '/api/v3/user',
scopes: 'user:email',
user_id_field: 'id',
username_field: 'login',
display_name_field: 'name',
email_field: 'email',
},
gitlab: {
name: 'GitLab',
authorization_endpoint: '/oauth/authorize',
token_endpoint: '/oauth/token',
user_info_endpoint: '/api/v4/user',
scopes: 'openid profile email',
user_id_field: 'id',
username_field: 'username',
display_name_field: 'name',
email_field: 'email',
},
gitea: {
name: 'Gitea',
authorization_endpoint: '/login/oauth/authorize',
token_endpoint: '/login/oauth/access_token',
user_info_endpoint: '/api/v1/user',
scopes: 'openid profile email',
user_id_field: 'id',
username_field: 'login',
display_name_field: 'full_name',
email_field: 'email',
},
nextcloud: {
name: 'Nextcloud',
authorization_endpoint: '/apps/oauth2/authorize',
token_endpoint: '/apps/oauth2/api/v1/token',
user_info_endpoint: '/ocs/v2.php/cloud/user?format=json',
scopes: 'openid profile email',
user_id_field: 'ocs.data.id',
username_field: 'ocs.data.id',
display_name_field: 'ocs.data.displayname',
email_field: 'ocs.data.email',
},
keycloak: {
name: 'Keycloak',
authorization_endpoint: '/realms/{realm}/protocol/openid-connect/auth',
token_endpoint: '/realms/{realm}/protocol/openid-connect/token',
user_info_endpoint: '/realms/{realm}/protocol/openid-connect/userinfo',
scopes: 'openid profile email',
user_id_field: 'sub',
username_field: 'preferred_username',
display_name_field: 'name',
email_field: 'email',
},
authentik: {
name: 'Authentik',
authorization_endpoint: '/application/o/authorize/',
token_endpoint: '/application/o/token/',
user_info_endpoint: '/application/o/userinfo/',
scopes: 'openid profile email',
user_id_field: 'sub',
username_field: 'preferred_username',
display_name_field: 'name',
email_field: 'email',
},
ory: {
name: 'ORY Hydra',
authorization_endpoint: '/oauth2/auth',
token_endpoint: '/oauth2/token',
user_info_endpoint: '/userinfo',
scopes: 'openid profile email',
user_id_field: 'sub',
username_field: 'preferred_username',
display_name_field: 'name',
email_field: 'email',
},
};
const CustomOAuthSetting = ({ serverAddress }) => {
const { t } = useTranslation();
const [providers, setProviders] = useState([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingProvider, setEditingProvider] = useState(null);
const [formValues, setFormValues] = useState({});
const [selectedPreset, setSelectedPreset] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const formApiRef = React.useRef(null);
const fetchProviders = async () => {
setLoading(true);
try {
const res = await API.get('/api/custom-oauth-provider/');
if (res.data.success) {
setProviders(res.data.data || []);
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('获取自定义 OAuth 提供商列表失败'));
}
setLoading(false);
};
useEffect(() => {
fetchProviders();
}, []);
const handleAdd = () => {
setEditingProvider(null);
setFormValues({
enabled: false,
scopes: 'openid profile email',
user_id_field: 'sub',
username_field: 'preferred_username',
display_name_field: 'name',
email_field: 'email',
auth_style: 0,
});
setSelectedPreset('');
setBaseUrl('');
setModalVisible(true);
};
const handleEdit = (provider) => {
setEditingProvider(provider);
setFormValues({ ...provider });
setSelectedPreset('');
setBaseUrl('');
setModalVisible(true);
};
const handleDelete = async (id) => {
try {
const res = await API.delete(`/api/custom-oauth-provider/${id}`);
if (res.data.success) {
showSuccess(t('删除成功'));
fetchProviders();
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('删除失败'));
}
};
const handleSubmit = async () => {
// Validate required fields
const requiredFields = [
'name',
'slug',
'client_id',
'authorization_endpoint',
'token_endpoint',
'user_info_endpoint',
];
if (!editingProvider) {
requiredFields.push('client_secret');
}
for (const field of requiredFields) {
if (!formValues[field]) {
showError(t(`请填写 ${field}`));
return;
}
}
// Validate endpoint URLs must be full URLs
const endpointFields = ['authorization_endpoint', 'token_endpoint', 'user_info_endpoint'];
for (const field of endpointFields) {
const value = formValues[field];
if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
// Check if user selected a preset but forgot to fill server address
if (selectedPreset && !baseUrl) {
showError(t('请先填写服务器地址,以自动生成完整的端点 URL'));
} else {
showError(t('端点 URL 必须是完整地址(以 http:// 或 https:// 开头)'));
}
return;
}
}
try {
let res;
if (editingProvider) {
res = await API.put(
`/api/custom-oauth-provider/${editingProvider.id}`,
formValues
);
} else {
res = await API.post('/api/custom-oauth-provider/', formValues);
}
if (res.data.success) {
showSuccess(editingProvider ? t('更新成功') : t('创建成功'));
setModalVisible(false);
fetchProviders();
} else {
showError(res.data.message);
}
} catch (error) {
showError(editingProvider ? t('更新失败') : t('创建失败'));
}
};
const handlePresetChange = (preset) => {
setSelectedPreset(preset);
if (preset && OAUTH_PRESETS[preset]) {
const presetConfig = OAUTH_PRESETS[preset];
const cleanUrl = baseUrl ? baseUrl.replace(/\/+$/, '') : '';
const newValues = {
name: presetConfig.name,
slug: preset,
scopes: presetConfig.scopes,
user_id_field: presetConfig.user_id_field,
username_field: presetConfig.username_field,
display_name_field: presetConfig.display_name_field,
email_field: presetConfig.email_field,
auth_style: presetConfig.auth_style ?? 0,
};
// Only fill endpoints if server address is provided
if (cleanUrl) {
newValues.authorization_endpoint = cleanUrl + presetConfig.authorization_endpoint;
newValues.token_endpoint = cleanUrl + presetConfig.token_endpoint;
newValues.user_info_endpoint = cleanUrl + presetConfig.user_info_endpoint;
}
setFormValues((prev) => ({ ...prev, ...newValues }));
// Update form fields directly via formApi
if (formApiRef.current) {
Object.entries(newValues).forEach(([key, value]) => {
formApiRef.current.setValue(key, value);
});
}
}
};
const handleBaseUrlChange = (url) => {
setBaseUrl(url);
if (url && selectedPreset && OAUTH_PRESETS[selectedPreset]) {
const presetConfig = OAUTH_PRESETS[selectedPreset];
const cleanUrl = url.replace(/\/+$/, ''); // Remove trailing slashes
const newValues = {
authorization_endpoint: cleanUrl + presetConfig.authorization_endpoint,
token_endpoint: cleanUrl + presetConfig.token_endpoint,
user_info_endpoint: cleanUrl + presetConfig.user_info_endpoint,
};
setFormValues((prev) => ({ ...prev, ...newValues }));
// Update form fields directly via formApi (use merge mode to preserve other fields)
if (formApiRef.current) {
Object.entries(newValues).forEach(([key, value]) => {
formApiRef.current.setValue(key, value);
});
}
}
};
const columns = [
{
title: t('名称'),
dataIndex: 'name',
key: 'name',
},
{
title: 'Slug',
dataIndex: 'slug',
key: 'slug',
render: (slug) => <Tag>{slug}</Tag>,
},
{
title: t('状态'),
dataIndex: 'enabled',
key: 'enabled',
render: (enabled) => (
<Tag color={enabled ? 'green' : 'grey'}>
{enabled ? t('已启用') : t('已禁用')}
</Tag>
),
},
{
title: t('Client ID'),
dataIndex: 'client_id',
key: 'client_id',
render: (id) => (id ? id.substring(0, 20) + '...' : '-'),
},
{
title: t('操作'),
key: 'actions',
render: (_, record) => (
<Space>
<Button
icon={<IconEdit />}
size="small"
onClick={() => handleEdit(record)}
>
{t('编辑')}
</Button>
<Popconfirm
title={t('确定要删除此 OAuth 提供商吗?')}
onConfirm={() => handleDelete(record.id)}
>
<Button icon={<IconDelete />} size="small" type="danger">
{t('删除')}
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Card>
<Form.Section text={t('自定义 OAuth 提供商')}>
<Banner
type="info"
description={
<>
{t(
'配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商'
)}
<br />
{t('回调 URL 格式')}: {serverAddress || t('网站地址')}/oauth/
{'{slug}'}
</>
}
style={{ marginBottom: 20 }}
/>
<Button
icon={<IconPlus />}
theme="solid"
onClick={handleAdd}
style={{ marginBottom: 16 }}
>
{t('添加 OAuth 提供商')}
</Button>
<Table
columns={columns}
dataSource={providers}
loading={loading}
rowKey="id"
pagination={false}
empty={t('暂无自定义 OAuth 提供商')}
/>
<Modal
title={editingProvider ? t('编辑 OAuth 提供商') : t('添加 OAuth 提供商')}
visible={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
okText={t('保存')}
cancelText={t('取消')}
width={800}
>
<Form
initValues={formValues}
onValueChange={(values) => setFormValues(values)}
getFormApi={(api) => (formApiRef.current = api)}
>
{!editingProvider && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={12}>
<Form.Select
field="preset"
label={t('预设模板')}
placeholder={t('选择预设模板(可选)')}
value={selectedPreset}
onChange={handlePresetChange}
optionList={[
{ value: '', label: t('自定义') },
...Object.entries(OAUTH_PRESETS).map(([key, config]) => ({
value: key,
label: config.name,
})),
]}
/>
</Col>
<Col span={12}>
<Form.Input
field="base_url"
label={
selectedPreset
? t('服务器地址') + ' *'
: t('服务器地址')
}
placeholder={t('例如https://gitea.example.com')}
value={baseUrl}
onChange={handleBaseUrlChange}
extraText={
selectedPreset
? t('必填:请输入服务器地址以自动生成完整端点 URL')
: t('选择预设模板后填写服务器地址可自动填充端点')
}
/>
</Col>
</Row>
)}
<Row gutter={16}>
<Col span={12}>
<Form.Input
field="name"
label={t('显示名称')}
placeholder={t('例如GitHub Enterprise')}
rules={[{ required: true, message: t('请输入显示名称') }]}
/>
</Col>
<Col span={12}>
<Form.Input
field="slug"
label="Slug"
placeholder={t('例如github-enterprise')}
extraText={t('URL 标识,只能包含小写字母、数字和连字符')}
rules={[{ required: true, message: t('请输入 Slug') }]}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Input
field="client_id"
label="Client ID"
placeholder={t('OAuth Client ID')}
rules={[{ required: true, message: t('请输入 Client ID') }]}
/>
</Col>
<Col span={12}>
<Form.Input
field="client_secret"
label="Client Secret"
type="password"
placeholder={
editingProvider
? t('留空则保持原有密钥')
: t('OAuth Client Secret')
}
rules={
editingProvider
? []
: [{ required: true, message: t('请输入 Client Secret') }]
}
/>
</Col>
</Row>
<Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
{t('OAuth 端点')}
</Text>
<Row gutter={16}>
<Col span={24}>
<Form.Input
field="authorization_endpoint"
label={t('Authorization Endpoint')}
placeholder={
selectedPreset && OAUTH_PRESETS[selectedPreset]
? t('填写服务器地址后自动生成:') +
OAUTH_PRESETS[selectedPreset].authorization_endpoint
: 'https://example.com/oauth/authorize'
}
rules={[
{ required: true, message: t('请输入 Authorization Endpoint') },
]}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Input
field="token_endpoint"
label={t('Token Endpoint')}
placeholder={
selectedPreset && OAUTH_PRESETS[selectedPreset]
? t('自动生成:') + OAUTH_PRESETS[selectedPreset].token_endpoint
: 'https://example.com/oauth/token'
}
rules={[{ required: true, message: t('请输入 Token Endpoint') }]}
/>
</Col>
<Col span={12}>
<Form.Input
field="user_info_endpoint"
label={t('User Info Endpoint')}
placeholder={
selectedPreset && OAUTH_PRESETS[selectedPreset]
? t('自动生成:') + OAUTH_PRESETS[selectedPreset].user_info_endpoint
: 'https://example.com/api/user'
}
rules={[
{ required: true, message: t('请输入 User Info Endpoint') },
]}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Input
field="scopes"
label={t('Scopes')}
placeholder="openid profile email"
/>
</Col>
<Col span={12}>
<Form.Input
field="well_known"
label={t('Well-Known URL')}
placeholder={t('OIDC Discovery 端点(可选)')}
/>
</Col>
</Row>
<Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
{t('字段映射')}
</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{t('配置如何从用户信息 API 响应中提取用户数据,支持 JSONPath 语法')}
</Text>
<Row gutter={16}>
<Col span={12}>
<Form.Input
field="user_id_field"
label={t('用户 ID 字段')}
placeholder={t('例如sub、id、data.user.id')}
extraText={t('用于唯一标识用户的字段路径')}
/>
</Col>
<Col span={12}>
<Form.Input
field="username_field"
label={t('用户名字段')}
placeholder={t('例如preferred_username、login')}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Input
field="display_name_field"
label={t('显示名称字段')}
placeholder={t('例如name、full_name')}
/>
</Col>
<Col span={12}>
<Form.Input
field="email_field"
label={t('邮箱字段')}
placeholder={t('例如email')}
/>
</Col>
</Row>
<Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
{t('高级选项')}
</Text>
<Row gutter={16}>
<Col span={12}>
<Form.Select
field="auth_style"
label={t('认证方式')}
optionList={[
{ value: 0, label: t('自动检测') },
{ value: 1, label: t('POST 参数') },
{ value: 2, label: t('Basic Auth 头') },
]}
/>
</Col>
<Col span={12}>
<Form.Checkbox field="enabled" noLabel>
{t('启用此 OAuth 提供商')}
</Form.Checkbox>
</Col>
</Row>
</Form>
</Modal>
</Form.Section>
</Card>
);
};
export default CustomOAuthSetting;

View File

@@ -42,6 +42,7 @@ import {
} from '../../helpers';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
import CustomOAuthSetting from './CustomOAuthSetting';
const SystemSetting = () => {
const { t } = useTranslation();
@@ -1534,6 +1535,8 @@ const SystemSetting = () => {
</Form.Section>
</Card>
<CustomOAuthSetting serverAddress={inputs.ServerAddress} />
<Card>
<Form.Section text={t('配置 WeChat Server')}>
<Text>{t('用以支持通过微信进行登录注册')}</Text>

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>

View File

@@ -294,6 +294,48 @@ export async function onLinuxDOOAuthClicked(
);
}
/**
* Initiate custom OAuth login
* @param {Object} provider - Custom OAuth provider config from status API
* @param {string} provider.slug - Provider slug (used for callback URL)
* @param {string} provider.client_id - OAuth client ID
* @param {string} provider.authorization_endpoint - Authorization URL
* @param {string} provider.scopes - OAuth scopes (space-separated)
* @param {Object} options - Options
* @param {boolean} options.shouldLogout - Whether to logout first
*/
export async function onCustomOAuthClicked(provider, options = {}) {
const state = await prepareOAuthState(options);
if (!state) return;
try {
const redirect_uri = `${window.location.origin}/oauth/${provider.slug}`;
// Check if authorization_endpoint is a full URL or relative path
let authUrl;
if (provider.authorization_endpoint.startsWith('http://') ||
provider.authorization_endpoint.startsWith('https://')) {
authUrl = new URL(provider.authorization_endpoint);
} else {
// Relative path - this is a configuration error, show error message
console.error('Custom OAuth authorization_endpoint must be a full URL:', provider.authorization_endpoint);
showError('OAuth 配置错误:授权端点必须是完整的 URL以 http:// 或 https:// 开头)');
return;
}
authUrl.searchParams.set('client_id', provider.client_id);
authUrl.searchParams.set('redirect_uri', redirect_uri);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', provider.scopes || 'openid profile email');
authUrl.searchParams.set('state', state);
window.open(authUrl.toString());
} catch (error) {
console.error('Failed to initiate custom OAuth:', error);
showError('OAuth 登录失败:' + (error.message || '未知错误'));
}
}
let channelModels = undefined;
export async function loadChannelModels() {
const res = await API.get('/api/models');

View File

@@ -2795,6 +2795,49 @@
"语言偏好": "Language Preference",
"选择您的首选界面语言,设置将自动保存并同步到所有设备": "Select your preferred interface language. Settings will be saved automatically and synced across all devices",
"语言偏好已保存": "Language preference saved",
"提示语言偏好会同步到您登录的所有设备并影响API返回的错误消息语言。": "Note: Language preference syncs across all your logged-in devices and affects the language of API error messages."
"提示语言偏好会同步到您登录的所有设备并影响API返回的错误消息语言。": "Note: Language preference syncs across all your logged-in devices and affects the language of API error messages.",
"自定义 OAuth 提供商": "Custom OAuth Providers",
"配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商": "Configure custom OAuth providers, supports GitHub Enterprise, GitLab, Gitea, Nextcloud, Keycloak, ORY and other OAuth 2.0 compatible identity providers",
"回调 URL 格式": "Callback URL format",
"添加提供商": "Add Provider",
"编辑提供商": "Edit Provider",
"选择预设...": "Select preset...",
"输入基础 URL": "Enter base URL",
"例如": "e.g.",
"提供商名称": "Provider Name",
"标识符 (Slug)": "Slug",
"授权端点": "Authorization Endpoint",
"令牌端点": "Token Endpoint",
"用户信息端点": "User Info Endpoint",
"用户 ID 字段": "User ID Field",
"支持 JSONPath如 sub, id, data.user.id": "Supports JSONPath, e.g. sub, id, data.user.id",
"用户名字段": "Username Field",
"支持 JSONPath如 preferred_username, login, data.user.username": "Supports JSONPath, e.g. preferred_username, login, data.user.username",
"显示名称字段": "Display Name Field",
"支持 JSONPath如 name, display_name, data.user.name": "Supports JSONPath, e.g. name, display_name, data.user.name",
"邮箱字段": "Email Field",
"支持 JSONPath如 email, data.user.email": "Supports JSONPath, e.g. email, data.user.email",
"授权范围 (Scopes)": "Scopes",
"认证方式": "Auth Style",
"自动检测": "Auto-detect",
"参数传递": "In Parameters",
"Basic Auth 头": "Basic Auth Header",
"暂无自定义 OAuth 提供商": "No custom OAuth providers",
"确定要删除该提供商吗?": "Are you sure you want to delete this provider?",
"创建成功": "Created successfully",
"更新成功": "Updated successfully",
"确认解绑": "Confirm Unbind",
"确定要解绑 {{name}} 吗?": "Are you sure you want to unbind {{name}}?",
"解绑成功": "Unbind successful",
"{{name}} ID": "{{name}} ID",
"使用 {{name}} 继续": "Continue with {{name}}",
"端点 URL 必须以 http:// 或 https:// 开头:": "Endpoint URL must start with http:// or https://: ",
"OAuth 配置错误:授权端点必须是完整的 URL以 http:// 或 https:// 开头)": "OAuth configuration error: Authorization endpoint must be a full URL (starting with http:// or https://)",
"OAuth 登录失败:": "OAuth login failed: ",
"必填:请输入服务器地址以自动生成完整端点 URL": "Required: Enter server address to auto-generate full endpoint URLs",
"填写服务器地址后自动生成:": "Auto-generated after entering server address: ",
"自动生成:": "Auto-generated: ",
"请先填写服务器地址,以自动生成完整的端点 URL": "Please enter the server address first to auto-generate full endpoint URLs",
"端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "Endpoint URL must be a full address (starting with http:// or https://)"
}
}

View File

@@ -2740,6 +2740,49 @@
"语言偏好": "语言偏好",
"选择您的首选界面语言,设置将自动保存并同步到所有设备": "选择您的首选界面语言,设置将自动保存并同步到所有设备",
"语言偏好已保存": "语言偏好已保存",
"提示语言偏好会同步到您登录的所有设备并影响API返回的错误消息语言。": "提示语言偏好会同步到您登录的所有设备并影响API返回的错误消息语言。"
"提示语言偏好会同步到您登录的所有设备并影响API返回的错误消息语言。": "提示语言偏好会同步到您登录的所有设备并影响API返回的错误消息语言。",
"自定义 OAuth 提供商": "自定义 OAuth 提供商",
"配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商": "配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商",
"回调 URL 格式": "回调 URL 格式",
"添加提供商": "添加提供商",
"编辑提供商": "编辑提供商",
"选择预设...": "选择预设...",
"输入基础 URL": "输入基础 URL",
"例如": "例如",
"提供商名称": "提供商名称",
"标识符 (Slug)": "标识符 (Slug)",
"授权端点": "授权端点",
"令牌端点": "令牌端点",
"用户信息端点": "用户信息端点",
"用户 ID 字段": "用户 ID 字段",
"支持 JSONPath如 sub, id, data.user.id": "支持 JSONPath如 sub, id, data.user.id",
"用户名字段": "用户名字段",
"支持 JSONPath如 preferred_username, login, data.user.username": "支持 JSONPath如 preferred_username, login, data.user.username",
"显示名称字段": "显示名称字段",
"支持 JSONPath如 name, display_name, data.user.name": "支持 JSONPath如 name, display_name, data.user.name",
"邮箱字段": "邮箱字段",
"支持 JSONPath如 email, data.user.email": "支持 JSONPath如 email, data.user.email",
"授权范围 (Scopes)": "授权范围 (Scopes)",
"认证方式": "认证方式",
"自动检测": "自动检测",
"参数传递": "参数传递",
"Basic Auth 头": "Basic Auth 头",
"暂无自定义 OAuth 提供商": "暂无自定义 OAuth 提供商",
"确定要删除该提供商吗?": "确定要删除该提供商吗?",
"创建成功": "创建成功",
"更新成功": "更新成功",
"确认解绑": "确认解绑",
"确定要解绑 {{name}} 吗?": "确定要解绑 {{name}} 吗?",
"解绑成功": "解绑成功",
"{{name}} ID": "{{name}} ID",
"使用 {{name}} 继续": "使用 {{name}} 继续",
"端点 URL 必须以 http:// 或 https:// 开头:": "端点 URL 必须以 http:// 或 https:// 开头:",
"OAuth 配置错误:授权端点必须是完整的 URL以 http:// 或 https:// 开头)": "OAuth 配置错误:授权端点必须是完整的 URL以 http:// 或 https:// 开头)",
"OAuth 登录失败:": "OAuth 登录失败:",
"必填:请输入服务器地址以自动生成完整端点 URL": "必填:请输入服务器地址以自动生成完整端点 URL",
"填写服务器地址后自动生成:": "填写服务器地址后自动生成:",
"自动生成:": "自动生成:",
"请先填写服务器地址,以自动生成完整的端点 URL": "请先填写服务器地址,以自动生成完整的端点 URL",
"端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)"
}
}