/* 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, { useEffect, useState } from 'react'; import { Button, Form, Row, Col, Typography, Modal, Banner, Card, Collapse, Switch, Table, Tag, Popconfirm, Space, } from '@douyinfe/semi-ui'; import { IconPlus, IconEdit, IconDelete, IconRefresh, } from '@douyinfe/semi-icons'; import { API, showError, showSuccess, getOAuthProviderIcon } 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 OAUTH_PRESET_ICONS = { 'github-enterprise': 'github', gitlab: 'gitlab', gitea: 'gitea', nextcloud: 'nextcloud', keycloak: 'keycloak', authentik: 'authentik', ory: 'openid', }; const getPresetIcon = (preset) => OAUTH_PRESET_ICONS[preset] || ''; const PRESET_RESET_VALUES = { name: '', slug: '', icon: '', authorization_endpoint: '', token_endpoint: '', user_info_endpoint: '', scopes: '', user_id_field: '', username_field: '', display_name_field: '', email_field: '', well_known: '', auth_style: 0, access_policy: '', access_denied_message: '', }; const DISCOVERY_FIELD_LABELS = { authorization_endpoint: 'Authorization Endpoint', token_endpoint: 'Token Endpoint', user_info_endpoint: 'User Info Endpoint', scopes: 'Scopes', user_id_field: 'User ID Field', username_field: 'Username Field', display_name_field: 'Display Name Field', email_field: 'Email Field', }; const ACCESS_POLICY_TEMPLATES = { level_active: `{ "logic": "and", "conditions": [ {"field": "trust_level", "op": "gte", "value": 2}, {"field": "active", "op": "eq", "value": true} ] }`, org_or_role: `{ "logic": "or", "conditions": [ {"field": "org", "op": "eq", "value": "core"}, {"field": "roles", "op": "contains", "value": "admin"} ] }`, }; const ACCESS_DENIED_TEMPLATES = { level_hint: '需要等级 {{required}},你当前等级 {{current}}(字段:{{field}})', org_hint: '仅限指定组织或角色访问。组织={{current.org}},角色={{current.roles}}', }; 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 [discoveryLoading, setDiscoveryLoading] = useState(false); const [discoveryInfo, setDiscoveryInfo] = useState(null); const [advancedActiveKeys, setAdvancedActiveKeys] = useState([]); const formApiRef = React.useRef(null); const mergeFormValues = (newValues) => { setFormValues((prev) => ({ ...prev, ...newValues })); if (!formApiRef.current) return; Object.entries(newValues).forEach(([key, value]) => { formApiRef.current.setValue(key, value); }); }; const getLatestFormValues = () => { const values = formApiRef.current?.getValues?.(); return values && typeof values === 'object' ? values : formValues; }; const normalizeBaseUrl = (url) => (url || '').trim().replace(/\/+$/, ''); const inferBaseUrlFromProvider = (provider) => { const endpoint = provider?.authorization_endpoint || provider?.token_endpoint; if (!endpoint) return ''; try { const url = new URL(endpoint); return `${url.protocol}//${url.host}`; } catch (error) { return ''; } }; const resetDiscoveryState = () => { setDiscoveryInfo(null); }; const closeModal = () => { setModalVisible(false); resetDiscoveryState(); setAdvancedActiveKeys([]); }; 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, icon: '', scopes: 'openid profile email', user_id_field: 'sub', username_field: 'preferred_username', display_name_field: 'name', email_field: 'email', auth_style: 0, access_policy: '', access_denied_message: '', }); setSelectedPreset(''); setBaseUrl(''); resetDiscoveryState(); setAdvancedActiveKeys([]); setModalVisible(true); }; const handleEdit = (provider) => { setEditingProvider(provider); setFormValues({ ...provider }); setSelectedPreset(OAUTH_PRESETS[provider.slug] ? provider.slug : ''); setBaseUrl(inferBaseUrlFromProvider(provider)); resetDiscoveryState(); setAdvancedActiveKeys([]); 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 () => { const currentValues = getLatestFormValues(); // 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 (!currentValues[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 = currentValues[field]; if (value && !value.startsWith('http://') && !value.startsWith('https://')) { // Check if user selected a preset but forgot to fill issuer URL if (selectedPreset && !baseUrl) { showError(t('请先填写 Issuer URL,以自动生成完整的端点 URL')); } else { showError(t('端点 URL 必须是完整地址(以 http:// 或 https:// 开头)')); } return; } } try { const payload = { ...currentValues, enabled: !!currentValues.enabled }; delete payload.preset; delete payload.base_url; let res; if (editingProvider) { res = await API.put( `/api/custom-oauth-provider/${editingProvider.id}`, payload ); } else { res = await API.post('/api/custom-oauth-provider/', payload); } if (res.data.success) { showSuccess(editingProvider ? t('更新成功') : t('创建成功')); closeModal(); fetchProviders(); } else { showError(res.data.message); } } catch (error) { showError( error?.response?.data?.message || (editingProvider ? t('更新失败') : t('创建失败')), ); } }; const handleFetchFromDiscovery = async () => { const cleanBaseUrl = normalizeBaseUrl(baseUrl); const configuredWellKnown = (formValues.well_known || '').trim(); const wellKnownUrl = configuredWellKnown || (cleanBaseUrl ? `${cleanBaseUrl}/.well-known/openid-configuration` : ''); if (!wellKnownUrl) { showError(t('请先填写 Discovery URL 或 Issuer URL')); return; } setDiscoveryLoading(true); try { const res = await API.post('/api/custom-oauth-provider/discovery', { well_known_url: configuredWellKnown || '', issuer_url: cleanBaseUrl || '', }); if (!res.data.success) { throw new Error(res.data.message || t('未知错误')); } const data = res.data.data?.discovery || {}; const resolvedWellKnown = res.data.data?.well_known_url || wellKnownUrl; const discoveredValues = { well_known: resolvedWellKnown, }; const autoFilledFields = []; if (data.authorization_endpoint) { discoveredValues.authorization_endpoint = data.authorization_endpoint; autoFilledFields.push('authorization_endpoint'); } if (data.token_endpoint) { discoveredValues.token_endpoint = data.token_endpoint; autoFilledFields.push('token_endpoint'); } if (data.userinfo_endpoint) { discoveredValues.user_info_endpoint = data.userinfo_endpoint; autoFilledFields.push('user_info_endpoint'); } const scopesSupported = Array.isArray(data.scopes_supported) ? data.scopes_supported : []; if (scopesSupported.length > 0 && !formValues.scopes) { const preferredScopes = ['openid', 'profile', 'email'].filter((scope) => scopesSupported.includes(scope), ); discoveredValues.scopes = preferredScopes.length > 0 ? preferredScopes.join(' ') : scopesSupported.slice(0, 5).join(' '); autoFilledFields.push('scopes'); } const claimsSupported = Array.isArray(data.claims_supported) ? data.claims_supported : []; const claimMap = { user_id_field: 'sub', username_field: 'preferred_username', display_name_field: 'name', email_field: 'email', }; Object.entries(claimMap).forEach(([field, claim]) => { if (!formValues[field] && claimsSupported.includes(claim)) { discoveredValues[field] = claim; autoFilledFields.push(field); } }); const hasCoreEndpoint = discoveredValues.authorization_endpoint || discoveredValues.token_endpoint || discoveredValues.user_info_endpoint; if (!hasCoreEndpoint) { showError(t('未在 Discovery 响应中找到可用的 OAuth 端点')); return; } mergeFormValues(discoveredValues); setDiscoveryInfo({ wellKnown: wellKnownUrl, autoFilledFields, scopesSupported: scopesSupported.slice(0, 12), claimsSupported: claimsSupported.slice(0, 12), }); showSuccess(t('已从 Discovery 自动填充配置')); } catch (error) { showError( t('获取 Discovery 配置失败:') + (error?.message || t('未知错误')), ); } finally { setDiscoveryLoading(false); } }; const handlePresetChange = (preset) => { setSelectedPreset(preset); resetDiscoveryState(); const cleanUrl = normalizeBaseUrl(baseUrl); if (!preset || !OAUTH_PRESETS[preset]) { mergeFormValues(PRESET_RESET_VALUES); return; } const presetConfig = OAUTH_PRESETS[preset]; const newValues = { ...PRESET_RESET_VALUES, name: presetConfig.name, slug: preset, icon: getPresetIcon(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, }; 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; } mergeFormValues(newValues); }; const handleBaseUrlChange = (url) => { setBaseUrl(url); if (url && selectedPreset && OAUTH_PRESETS[selectedPreset]) { const presetConfig = OAUTH_PRESETS[selectedPreset]; const cleanUrl = normalizeBaseUrl(url); const newValues = { authorization_endpoint: cleanUrl + presetConfig.authorization_endpoint, token_endpoint: cleanUrl + presetConfig.token_endpoint, user_info_endpoint: cleanUrl + presetConfig.user_info_endpoint, }; mergeFormValues(newValues); } }; const applyAccessPolicyTemplate = (templateKey) => { const template = ACCESS_POLICY_TEMPLATES[templateKey]; if (!template) return; mergeFormValues({ access_policy: template }); showSuccess(t('已填充策略模板')); }; const applyDeniedTemplate = (templateKey) => { const template = ACCESS_DENIED_TEMPLATES[templateKey]; if (!template) return; mergeFormValues({ access_denied_message: template }); showSuccess(t('已填充提示模板')); }; const columns = [ { title: t('图标'), dataIndex: 'icon', key: 'icon', width: 80, render: (icon) => getOAuthProviderIcon(icon || '', 18), }, { title: t('名称'), dataIndex: 'name', key: 'name', }, { title: 'Slug', dataIndex: 'slug', key: 'slug', render: (slug) => {slug}, }, { title: t('状态'), dataIndex: 'enabled', key: 'enabled', render: (enabled) => ( {enabled ? t('已启用') : t('已禁用')} ), }, { title: t('Client ID'), dataIndex: 'client_id', key: 'client_id', render: (id) => { if (!id) return '-'; return id.length > 20 ? `${id.substring(0, 20)}...` : id; }, }, { title: t('操作'), key: 'actions', render: (_, record) => ( handleDelete(record.id)} > ), }, ]; const discoveryAutoFilledLabels = (discoveryInfo?.autoFilledFields || []) .map((field) => DISCOVERY_FIELD_LABELS[field] || field) .join(', '); return ( {t( '配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商' )}
{t('回调 URL 格式')}: {serverAddress || t('网站地址')}/oauth/ {'{slug}'} } style={{ marginBottom: 20 }} /> {t('启用供应商')} mergeFormValues({ enabled: !!checked })} /> {formValues.enabled ? t('已启用') : t('已禁用')} } >
{ setFormValues((prev) => ({ ...prev, ...getLatestFormValues() })); }} getFormApi={(api) => (formApiRef.current = api)} > {t('Configuration')} {t('先填写配置,再自动填充 OAuth 端点,能显著减少手工输入')} {discoveryInfo && (
{t('已从 Discovery 获取配置,可继续手动修改所有字段。')}
{discoveryAutoFilledLabels ? (
{t('自动填充字段')}: {' '} {discoveryAutoFilledLabels}
) : null} {discoveryInfo.scopesSupported?.length ? (
{t('Discovery scopes')}: {' '} {discoveryInfo.scopesSupported.join(', ')}
) : null} {discoveryInfo.claimsSupported?.length ? (
{t('Discovery claims')}: {' '} {discoveryInfo.claimsSupported.join(', ')}
) : null} } /> )}
({ value: key, label: config.name, })), ]} />
{t( '图标使用 react-icons(Simple Icons)或 URL/emoji,例如:github、gitlab、si:google', )} } showClear />
{getOAuthProviderIcon(formValues.icon || '', 24)}
{t('OAuth 端点')} {t('字段映射')} {t('配置如何从用户信息 API 响应中提取用户数据,支持 JSONPath 语法')} { const keys = Array.isArray(activeKey) ? activeKey : [activeKey]; setAdvancedActiveKeys(keys.filter(Boolean)); }} > {t('准入策略')} {t('可选:基于用户信息 JSON 做组合条件准入,条件不满足时返回自定义提示')} mergeFormValues({ access_policy: value })} label={t('准入策略 JSON(可选)')} rows={6} placeholder={`{ "logic": "and", "conditions": [ {"field": "trust_level", "op": "gte", "value": 2}, {"field": "active", "op": "eq", "value": true} ] }`} extraText={t('支持逻辑 and/or 与嵌套 groups;操作符支持 eq/ne/gt/gte/lt/lte/in/not_in/contains/exists')} showClear /> mergeFormValues({ access_denied_message: value })} label={t('拒绝提示模板(可选)')} placeholder={t('例如:需要等级 {{required}},你当前等级 {{current}}')} extraText={t('可用变量:{{provider}} {{field}} {{op}} {{required}} {{current}} 以及 {{current.path}}')} showClear /> ); }; export default CustomOAuthSetting;