|
|
@@ -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 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,
|
|
|
+ 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,
|
|
|
};
|
|
|
- // 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;
|
|
|
+ const autoFilledFields = [];
|
|
|
+ if (data.authorization_endpoint) {
|
|
|
+ discoveredValues.authorization_endpoint = data.authorization_endpoint;
|
|
|
+ autoFilledFields.push('authorization_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);
|
|
|
- });
|
|
|
+ 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 = 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 (
|
|
|
<Card>
|
|
|
<Form.Section text={t('自定义 OAuth 提供商')}>
|
|
|
@@ -391,56 +634,142 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|
|
<Modal
|
|
|
title={editingProvider ? t('编辑 OAuth 提供商') : t('添加 OAuth 提供商')}
|
|
|
visible={modalVisible}
|
|
|
- onOk={handleSubmit}
|
|
|
- onCancel={() => setModalVisible(false)}
|
|
|
- okText={t('保存')}
|
|
|
- cancelText={t('取消')}
|
|
|
- width={800}
|
|
|
+ onCancel={closeModal}
|
|
|
+ width={860}
|
|
|
+ centered
|
|
|
+ bodyStyle={{ maxHeight: '72vh', overflowY: 'auto', paddingRight: 6 }}
|
|
|
+ footer={
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ display: 'flex',
|
|
|
+ justifyContent: 'flex-end',
|
|
|
+ alignItems: 'center',
|
|
|
+ gap: 12,
|
|
|
+ flexWrap: 'wrap',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Space spacing={8} align='center'>
|
|
|
+ <Text type='secondary'>{t('启用供应商')}</Text>
|
|
|
+ <Switch
|
|
|
+ checked={!!formValues.enabled}
|
|
|
+ size='large'
|
|
|
+ onChange={(checked) => mergeFormValues({ enabled: !!checked })}
|
|
|
+ />
|
|
|
+ <Tag color={formValues.enabled ? 'green' : 'grey'}>
|
|
|
+ {formValues.enabled ? t('已启用') : t('已禁用')}
|
|
|
+ </Tag>
|
|
|
+ </Space>
|
|
|
+ <Button onClick={closeModal}>{t('取消')}</Button>
|
|
|
+ <Button type='primary' onClick={handleSubmit}>
|
|
|
+ {t('保存')}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
>
|
|
|
<Form
|
|
|
initValues={formValues}
|
|
|
- onValueChange={(values) => setFormValues(values)}
|
|
|
+ onValueChange={() => {
|
|
|
+ setFormValues((prev) => ({ ...prev, ...getLatestFormValues() }));
|
|
|
+ }}
|
|
|
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>
|
|
|
+ <Text strong style={{ display: 'block', marginBottom: 8 }}>
|
|
|
+ {t('Configuration')}
|
|
|
+ </Text>
|
|
|
+ <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
|
|
+ {t('先填写配置,再自动填充 OAuth 端点,能显著减少手工输入')}
|
|
|
+ </Text>
|
|
|
+ {discoveryInfo && (
|
|
|
+ <Banner
|
|
|
+ type='success'
|
|
|
+ closeIcon={null}
|
|
|
+ style={{ marginBottom: 12 }}
|
|
|
+ description={
|
|
|
+ <div>
|
|
|
+ <div>
|
|
|
+ {t('已从 Discovery 获取配置,可继续手动修改所有字段。')}
|
|
|
+ </div>
|
|
|
+ {discoveryAutoFilledLabels ? (
|
|
|
+ <div>
|
|
|
+ {t('自动填充字段')}:
|
|
|
+ {' '}
|
|
|
+ {discoveryAutoFilledLabels}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ {discoveryInfo.scopesSupported?.length ? (
|
|
|
+ <div>
|
|
|
+ {t('Discovery scopes')}:
|
|
|
+ {' '}
|
|
|
+ {discoveryInfo.scopesSupported.join(', ')}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ {discoveryInfo.claimsSupported?.length ? (
|
|
|
+ <div>
|
|
|
+ {t('Discovery claims')}:
|
|
|
+ {' '}
|
|
|
+ {discoveryInfo.claimsSupported.join(', ')}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ />
|
|
|
)}
|
|
|
|
|
|
+ <Row gutter={16}>
|
|
|
+ <Col span={8}>
|
|
|
+ <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={10}>
|
|
|
+ <Form.Input
|
|
|
+ field="base_url"
|
|
|
+ label={t('发行者 URL(Issuer URL)')}
|
|
|
+ placeholder={t('例如:https://gitea.example.com')}
|
|
|
+ value={baseUrl}
|
|
|
+ onChange={handleBaseUrlChange}
|
|
|
+ extraText={
|
|
|
+ selectedPreset
|
|
|
+ ? t('填写后会自动拼接预设端点')
|
|
|
+ : t('可选:用于自动生成端点或 Discovery URL')
|
|
|
+ }
|
|
|
+ />
|
|
|
+ </Col>
|
|
|
+ <Col span={6}>
|
|
|
+ <div style={{ display: 'flex', alignItems: 'flex-end', height: '100%' }}>
|
|
|
+ <Button
|
|
|
+ icon={<IconRefresh />}
|
|
|
+ onClick={handleFetchFromDiscovery}
|
|
|
+ loading={discoveryLoading}
|
|
|
+ block
|
|
|
+ >
|
|
|
+ {t('获取 Discovery 配置')}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </Col>
|
|
|
+ </Row>
|
|
|
+ <Row gutter={16}>
|
|
|
+ <Col span={24}>
|
|
|
+ <Form.Input
|
|
|
+ field="well_known"
|
|
|
+ label={t('发现文档地址(Discovery URL,可选)')}
|
|
|
+ placeholder={t('例如:https://example.com/.well-known/openid-configuration')}
|
|
|
+ extraText={t('可留空;留空时会尝试使用 Issuer URL + /.well-known/openid-configuration')}
|
|
|
+ />
|
|
|
+ </Col>
|
|
|
+ </Row>
|
|
|
+
|
|
|
<Row gutter={16}>
|
|
|
<Col span={12}>
|
|
|
<Form.Input
|
|
|
@@ -461,6 +790,41 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|
|
</Col>
|
|
|
</Row>
|
|
|
|
|
|
+ <Row gutter={16}>
|
|
|
+ <Col span={18}>
|
|
|
+ <Form.Input
|
|
|
+ field='icon'
|
|
|
+ label={t('图标')}
|
|
|
+ placeholder={t('例如:github / si:google / https://example.com/logo.png / 🐱')}
|
|
|
+ extraText={
|
|
|
+ <span>
|
|
|
+ {t(
|
|
|
+ '图标使用 react-icons(Simple Icons)或 URL/emoji,例如:github、gitlab、si:google',
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ }
|
|
|
+ showClear
|
|
|
+ />
|
|
|
+ </Col>
|
|
|
+ <Col span={6} style={{ display: 'flex', alignItems: 'flex-end' }}>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ minHeight: 74,
|
|
|
+ border: '1px solid var(--semi-color-border)',
|
|
|
+ borderRadius: 8,
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ marginBottom: 24,
|
|
|
+ background: 'var(--semi-color-fill-0)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {getOAuthProviderIcon(formValues.icon || '', 24)}
|
|
|
+ </div>
|
|
|
+ </Col>
|
|
|
+ </Row>
|
|
|
+
|
|
|
<Row gutter={16}>
|
|
|
<Col span={12}>
|
|
|
<Form.Input
|
|
|
@@ -500,7 +864,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|
|
label={t('Authorization Endpoint')}
|
|
|
placeholder={
|
|
|
selectedPreset && OAUTH_PRESETS[selectedPreset]
|
|
|
- ? t('填写服务器地址后自动生成:') +
|
|
|
+ ? t('填写 Issuer URL 后自动生成:') +
|
|
|
OAUTH_PRESETS[selectedPreset].authorization_endpoint
|
|
|
: 'https://example.com/oauth/authorize'
|
|
|
}
|
|
|
@@ -544,15 +908,14 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|
|
<Col span={12}>
|
|
|
<Form.Input
|
|
|
field="scopes"
|
|
|
- label={t('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 端点(可选)')}
|
|
|
+ extraText={
|
|
|
+ discoveryInfo?.scopesSupported?.length
|
|
|
+ ? t('Discovery 建议 scopes:') +
|
|
|
+ discoveryInfo.scopesSupported.join(', ')
|
|
|
+ : t('可手动填写,多个 scope 用空格分隔')
|
|
|
+ }
|
|
|
/>
|
|
|
</Col>
|
|
|
</Row>
|
|
|
@@ -568,7 +931,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|
|
<Col span={12}>
|
|
|
<Form.Input
|
|
|
field="user_id_field"
|
|
|
- label={t('用户 ID 字段')}
|
|
|
+ label={t('用户 ID 字段(可选)')}
|
|
|
placeholder={t('例如:sub、id、data.user.id')}
|
|
|
extraText={t('用于唯一标识用户的字段路径')}
|
|
|
/>
|
|
|
@@ -576,7 +939,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|
|
<Col span={12}>
|
|
|
<Form.Input
|
|
|
field="username_field"
|
|
|
- label={t('用户名字段')}
|
|
|
+ label={t('用户名字段(可选)')}
|
|
|
placeholder={t('例如:preferred_username、login')}
|
|
|
/>
|
|
|
</Col>
|
|
|
@@ -586,41 +949,100 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|
|
<Col span={12}>
|
|
|
<Form.Input
|
|
|
field="display_name_field"
|
|
|
- label={t('显示名称字段')}
|
|
|
+ label={t('显示名称字段(可选)')}
|
|
|
placeholder={t('例如:name、full_name')}
|
|
|
/>
|
|
|
</Col>
|
|
|
<Col span={12}>
|
|
|
<Form.Input
|
|
|
field="email_field"
|
|
|
- label={t('邮箱字段')}
|
|
|
+ label={t('邮箱字段(可选)')}
|
|
|
placeholder={t('例如:email')}
|
|
|
/>
|
|
|
</Col>
|
|
|
</Row>
|
|
|
|
|
|
- <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
|
|
|
- {t('高级选项')}
|
|
|
- </Text>
|
|
|
+ <Collapse
|
|
|
+ keepDOM
|
|
|
+ activeKey={advancedActiveKeys}
|
|
|
+ style={{ marginTop: 16 }}
|
|
|
+ onChange={(activeKey) => {
|
|
|
+ const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
|
|
|
+ setAdvancedActiveKeys(keys.filter(Boolean));
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Collapse.Panel header={t('高级选项')} itemKey='advanced'>
|
|
|
+ <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>
|
|
|
+ </Row>
|
|
|
|
|
|
- <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>
|
|
|
+ <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
|
|
|
+ {t('准入策略')}
|
|
|
+ </Text>
|
|
|
+ <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
|
|
+ {t('可选:基于用户信息 JSON 做组合条件准入,条件不满足时返回自定义提示')}
|
|
|
+ </Text>
|
|
|
+ <Row gutter={16}>
|
|
|
+ <Col span={24}>
|
|
|
+ <Form.TextArea
|
|
|
+ field='access_policy'
|
|
|
+ value={formValues.access_policy || ''}
|
|
|
+ onChange={(value) => 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
|
|
|
+ />
|
|
|
+ <Space spacing={8} style={{ marginTop: 8 }}>
|
|
|
+ <Button size='small' theme='light' onClick={() => applyAccessPolicyTemplate('level_active')}>
|
|
|
+ {t('填充模板:等级+激活')}
|
|
|
+ </Button>
|
|
|
+ <Button size='small' theme='light' onClick={() => applyAccessPolicyTemplate('org_or_role')}>
|
|
|
+ {t('填充模板:组织或角色')}
|
|
|
+ </Button>
|
|
|
+ </Space>
|
|
|
+ </Col>
|
|
|
+ </Row>
|
|
|
+ <Row gutter={16}>
|
|
|
+ <Col span={24}>
|
|
|
+ <Form.Input
|
|
|
+ field='access_denied_message'
|
|
|
+ value={formValues.access_denied_message || ''}
|
|
|
+ onChange={(value) => mergeFormValues({ access_denied_message: value })}
|
|
|
+ label={t('拒绝提示模板(可选)')}
|
|
|
+ placeholder={t('例如:需要等级 {{required}},你当前等级 {{current}}')}
|
|
|
+ extraText={t('可用变量:{{provider}} {{field}} {{op}} {{required}} {{current}} 以及 {{current.path}}')}
|
|
|
+ showClear
|
|
|
+ />
|
|
|
+ <Space spacing={8} style={{ marginTop: 8 }}>
|
|
|
+ <Button size='small' theme='light' onClick={() => applyDeniedTemplate('level_hint')}>
|
|
|
+ {t('填充模板:等级提示')}
|
|
|
+ </Button>
|
|
|
+ <Button size='small' theme='light' onClick={() => applyDeniedTemplate('org_hint')}>
|
|
|
+ {t('填充模板:组织提示')}
|
|
|
+ </Button>
|
|
|
+ </Space>
|
|
|
+ </Col>
|
|
|
+ </Row>
|
|
|
+ </Collapse.Panel>
|
|
|
+ </Collapse>
|
|
|
</Form>
|
|
|
</Modal>
|
|
|
</Form.Section>
|