mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-18 20:07:26 +00:00
imporve oauth provider UI/UX (#2983)
* feat: imporve UI/UX * fix: stabilize provider enabled toggle and polish custom OAuth settings UX * fix: add access policy/message templates and persist advanced fields reliably * fix: move template fill actions below fields and keep advanced form flow cleaner
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
getSystemName,
|
||||
getOAuthProviderIcon,
|
||||
setUserData,
|
||||
onGitHubOAuthClicked,
|
||||
onDiscordOAuthClicked,
|
||||
@@ -130,6 +131,17 @@ const LoginForm = () => {
|
||||
return {};
|
||||
}
|
||||
}, [statusState?.status]);
|
||||
const hasCustomOAuthProviders =
|
||||
(status.custom_oauth_providers || []).length > 0;
|
||||
const hasOAuthLoginOptions = Boolean(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth ||
|
||||
hasCustomOAuthProviders,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.turnstile_check) {
|
||||
@@ -598,7 +610,7 @@ const LoginForm = () => {
|
||||
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' />}
|
||||
icon={getOAuthProviderIcon(provider.icon || '', 20)}
|
||||
onClick={() => handleCustomOAuthClick(provider)}
|
||||
loading={customOAuthLoading[provider.slug]}
|
||||
>
|
||||
@@ -817,12 +829,7 @@ const LoginForm = () => {
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{(status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth) && (
|
||||
{hasOAuthLoginOptions && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
@@ -952,14 +959,7 @@ const LoginForm = () => {
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
{showEmailLogin ||
|
||||
!(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth
|
||||
)
|
||||
!hasOAuthLoginOptions
|
||||
? renderEmailLoginForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
getSystemName,
|
||||
getOAuthProviderIcon,
|
||||
setUserData,
|
||||
onDiscordOAuthClicked,
|
||||
onCustomOAuthClicked,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import {
|
||||
@@ -98,6 +100,7 @@ const RegisterForm = () => {
|
||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
|
||||
useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
@@ -126,6 +129,17 @@ const RegisterForm = () => {
|
||||
return {};
|
||||
}
|
||||
}, [statusState?.status]);
|
||||
const hasCustomOAuthProviders =
|
||||
(status.custom_oauth_providers || []).length > 0;
|
||||
const hasOAuthRegisterOptions = Boolean(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth ||
|
||||
hasCustomOAuthProviders,
|
||||
);
|
||||
|
||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
||||
|
||||
@@ -319,6 +333,17 @@ const RegisterForm = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomOAuthClick = (provider) => {
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
|
||||
try {
|
||||
onCustomOAuthClicked(provider, { shouldLogout: true });
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false }));
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailRegisterClick = () => {
|
||||
setEmailRegisterLoading(true);
|
||||
setShowEmailRegister(true);
|
||||
@@ -469,6 +494,23 @@ const RegisterForm = () => {
|
||||
</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={getOAuthProviderIcon(provider.icon || '', 20)}
|
||||
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
|
||||
@@ -650,12 +692,7 @@ const RegisterForm = () => {
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{(status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth) && (
|
||||
{hasOAuthRegisterOptions && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
@@ -745,14 +782,7 @@ const RegisterForm = () => {
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
{showEmailRegister ||
|
||||
!(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth
|
||||
)
|
||||
!hasOAuthRegisterOptions
|
||||
? renderEmailRegisterForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
onLinuxDOOAuthClicked,
|
||||
onDiscordOAuthClicked,
|
||||
onCustomOAuthClicked,
|
||||
getOAuthProviderIcon,
|
||||
} from '../../../../helpers';
|
||||
import TwoFASetting from '../components/TwoFASetting';
|
||||
|
||||
@@ -148,12 +149,14 @@ const AccountManagement = ({
|
||||
|
||||
// Check if custom OAuth provider is bound
|
||||
const isCustomOAuthBound = (providerId) => {
|
||||
return customOAuthBindings.some((b) => b.provider_id === providerId);
|
||||
const normalizedId = Number(providerId);
|
||||
return customOAuthBindings.some((b) => Number(b.provider_id) === normalizedId);
|
||||
};
|
||||
|
||||
// Get binding info for a provider
|
||||
const getCustomOAuthBinding = (providerId) => {
|
||||
return customOAuthBindings.find((b) => b.provider_id === providerId);
|
||||
const normalizedId = Number(providerId);
|
||||
return customOAuthBindings.find((b) => Number(b.provider_id) === normalizedId);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -524,10 +527,10 @@ const AccountManagement = ({
|
||||
<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'
|
||||
/>
|
||||
{getOAuthProviderIcon(
|
||||
provider.icon || binding?.provider_icon || '',
|
||||
20,
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium text-gray-900'>
|
||||
|
||||
@@ -76,6 +76,31 @@ import {
|
||||
Server,
|
||||
CalendarClock,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
SiAtlassian,
|
||||
SiAuth0,
|
||||
SiAuthentik,
|
||||
SiBitbucket,
|
||||
SiDiscord,
|
||||
SiDropbox,
|
||||
SiFacebook,
|
||||
SiGitea,
|
||||
SiGithub,
|
||||
SiGitlab,
|
||||
SiGoogle,
|
||||
SiKeycloak,
|
||||
SiLinkedin,
|
||||
SiNextcloud,
|
||||
SiNotion,
|
||||
SiOkta,
|
||||
SiOpenid,
|
||||
SiReddit,
|
||||
SiSlack,
|
||||
SiTelegram,
|
||||
SiTwitch,
|
||||
SiWechat,
|
||||
SiX,
|
||||
} from 'react-icons/si';
|
||||
|
||||
// 获取侧边栏Lucide图标组件
|
||||
export function getLucideIcon(key, selected = false) {
|
||||
@@ -472,6 +497,106 @@ export function getLobeHubIcon(iconName, size = 14) {
|
||||
return <IconComponent {...props} />;
|
||||
}
|
||||
|
||||
const oauthProviderIconMap = {
|
||||
github: SiGithub,
|
||||
gitlab: SiGitlab,
|
||||
gitea: SiGitea,
|
||||
google: SiGoogle,
|
||||
discord: SiDiscord,
|
||||
facebook: SiFacebook,
|
||||
linkedin: SiLinkedin,
|
||||
x: SiX,
|
||||
twitter: SiX,
|
||||
slack: SiSlack,
|
||||
telegram: SiTelegram,
|
||||
wechat: SiWechat,
|
||||
keycloak: SiKeycloak,
|
||||
nextcloud: SiNextcloud,
|
||||
authentik: SiAuthentik,
|
||||
openid: SiOpenid,
|
||||
okta: SiOkta,
|
||||
auth0: SiAuth0,
|
||||
atlassian: SiAtlassian,
|
||||
bitbucket: SiBitbucket,
|
||||
notion: SiNotion,
|
||||
twitch: SiTwitch,
|
||||
reddit: SiReddit,
|
||||
dropbox: SiDropbox,
|
||||
};
|
||||
|
||||
function isHttpUrl(value) {
|
||||
return /^https?:\/\//i.test(value || '');
|
||||
}
|
||||
|
||||
function isSimpleEmoji(value) {
|
||||
if (!value) return false;
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed.length > 0 && trimmed.length <= 4 && !isHttpUrl(trimmed);
|
||||
}
|
||||
|
||||
function normalizeOAuthIconKey(raw) {
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^ri:/, '')
|
||||
.replace(/^react-icons:/, '')
|
||||
.replace(/^si:/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render custom OAuth provider icon with react-icons or URL/emoji fallback.
|
||||
* Supported formats:
|
||||
* - react-icons simple key: github / gitlab / google / keycloak
|
||||
* - prefixed key: ri:github / si:github
|
||||
* - full URL image: https://example.com/logo.png
|
||||
* - emoji: 🐱
|
||||
*/
|
||||
export function getOAuthProviderIcon(iconName, size = 20) {
|
||||
const raw = String(iconName || '').trim();
|
||||
const iconSize = Number(size) > 0 ? Number(size) : 20;
|
||||
|
||||
if (!raw) {
|
||||
return <Layers size={iconSize} color='var(--semi-color-text-2)' />;
|
||||
}
|
||||
|
||||
if (isHttpUrl(raw)) {
|
||||
return (
|
||||
<img
|
||||
src={raw}
|
||||
alt='provider icon'
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ borderRadius: 4, objectFit: 'cover' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSimpleEmoji(raw)) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
lineHeight: `${iconSize}px`,
|
||||
textAlign: 'center',
|
||||
display: 'inline-block',
|
||||
fontSize: Math.max(Math.floor(iconSize * 0.8), 14),
|
||||
}}
|
||||
>
|
||||
{raw}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const key = normalizeOAuthIconKey(raw);
|
||||
const IconComp = oauthProviderIconMap[key];
|
||||
if (IconComp) {
|
||||
return <IconComp size={iconSize} />;
|
||||
}
|
||||
|
||||
return <Avatar size='extra-extra-small'>{raw.charAt(0).toUpperCase()}</Avatar>;
|
||||
}
|
||||
|
||||
// 颜色列表
|
||||
const colors = [
|
||||
'amber',
|
||||
|
||||
Reference in New Issue
Block a user