{showEmailRegister ||
- !(
- status.github_oauth ||
- status.discord_oauth ||
- status.oidc_enabled ||
- status.wechat_login ||
- status.linuxdo_oauth ||
- status.telegram_oauth
- )
+ !hasOAuthRegisterOptions
? renderEmailRegisterForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
diff --git a/web/src/components/settings/CustomOAuthSetting.jsx b/web/src/components/settings/CustomOAuthSetting.jsx
index 4b6df4c81..0912160be 100644
--- a/web/src/components/settings/CustomOAuthSetting.jsx
+++ b/web/src/components/settings/CustomOAuthSetting.jsx
@@ -27,14 +27,20 @@ import {
Modal,
Banner,
Card,
+ Collapse,
+ Switch,
Table,
Tag,
Popconfirm,
Space,
- Select,
} from '@douyinfe/semi-ui';
-import { IconPlus, IconEdit, IconDelete } from '@douyinfe/semi-icons';
-import { API, showError, showSuccess } from '../../helpers';
+import {
+ IconPlus,
+ IconEdit,
+ IconDelete,
+ IconRefresh,
+} from '@douyinfe/semi-icons';
+import { API, showError, showSuccess, getOAuthProviderIcon } from '../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
@@ -120,6 +126,69 @@ const OAUTH_PRESETS = {
},
};
+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([]);
@@ -129,8 +198,47 @@ const CustomOAuthSetting = ({ serverAddress }) => {
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 {
@@ -154,23 +262,30 @@ const CustomOAuthSetting = ({ serverAddress }) => {
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('');
- setBaseUrl('');
+ setSelectedPreset(OAUTH_PRESETS[provider.slug] ? provider.slug : '');
+ setBaseUrl(inferBaseUrlFromProvider(provider));
+ resetDiscoveryState();
+ setAdvancedActiveKeys([]);
setModalVisible(true);
};
@@ -189,6 +304,8 @@ const CustomOAuthSetting = ({ serverAddress }) => {
};
const handleSubmit = async () => {
+ const currentValues = getLatestFormValues();
+
// Validate required fields
const requiredFields = [
'name',
@@ -204,7 +321,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
}
for (const field of requiredFields) {
- if (!formValues[field]) {
+ if (!currentValues[field]) {
showError(t(`请填写 ${field}`));
return;
}
@@ -213,11 +330,11 @@ const CustomOAuthSetting = ({ serverAddress }) => {
// 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];
+ const value = currentValues[field];
if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
- // Check if user selected a preset but forgot to fill server address
+ // Check if user selected a preset but forgot to fill issuer URL
if (selectedPreset && !baseUrl) {
- showError(t('请先填写服务器地址,以自动生成完整的端点 URL'));
+ showError(t('请先填写 Issuer URL,以自动生成完整的端点 URL'));
} else {
showError(t('端点 URL 必须是完整地址(以 http:// 或 https:// 开头)'));
}
@@ -226,80 +343,199 @@ const CustomOAuthSetting = ({ serverAddress }) => {
}
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}`,
- formValues
+ payload
);
} else {
- res = await API.post('/api/custom-oauth-provider/', formValues);
+ res = await API.post('/api/custom-oauth-provider/', payload);
}
if (res.data.success) {
showSuccess(editingProvider ? t('更新成功') : t('创建成功'));
- setModalVisible(false);
+ closeModal();
fetchProviders();
} else {
showError(res.data.message);
}
} catch (error) {
- showError(editingProvider ? t('更新失败') : t('创建失败'));
+ 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);
- 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);
- });
- }
+ 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 = url.replace(/\/+$/, ''); // Remove trailing slashes
+ 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,
};
- 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);
- });
- }
+ 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',
@@ -325,7 +561,10 @@ const CustomOAuthSetting = ({ serverAddress }) => {
title: t('Client ID'),
dataIndex: 'client_id',
key: 'client_id',
- render: (id) => (id ? id.substring(0, 20) + '...' : '-'),
+ render: (id) => {
+ if (!id) return '-';
+ return id.length > 20 ? `${id.substring(0, 20)}...` : id;
+ },
},
{
title: t('操作'),
@@ -352,6 +591,10 @@ const CustomOAuthSetting = ({ serverAddress }) => {
},
];
+ const discoveryAutoFilledLabels = (discoveryInfo?.autoFilledFields || [])
+ .map((field) => DISCOVERY_FIELD_LABELS[field] || field)
+ .join(', ');
+
return (
@@ -391,56 +634,142 @@ const CustomOAuthSetting = ({ serverAddress }) => {
setModalVisible(false)}
- okText={t('保存')}
- cancelText={t('取消')}
- width={800}
+ onCancel={closeModal}
+ width={860}
+ centered
+ bodyStyle={{ maxHeight: '72vh', overflowY: 'auto', paddingRight: 6 }}
+ footer={
+
+
+ {t('启用供应商')}
+ mergeFormValues({ enabled: !!checked })}
+ />
+
+ {formValues.enabled ? t('已启用') : t('已禁用')}
+
+
+
+
+
+ }
>
+ }
+ />
)}
+