| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053 |
- /*
- Copyright (C) 2025 QuantumNous
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
- For commercial licensing, please contact support@quantumnous.com
- */
- import React, { useEffect, useState } from 'react';
- import {
- Button,
- Form,
- Row,
- Col,
- Typography,
- Modal,
- Banner,
- Card,
- 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) => <Tag>{slug}</Tag>,
- },
- {
- title: t('状态'),
- dataIndex: 'enabled',
- key: 'enabled',
- render: (enabled) => (
- <Tag color={enabled ? 'green' : 'grey'}>
- {enabled ? t('已启用') : t('已禁用')}
- </Tag>
- ),
- },
- {
- title: t('Client ID'),
- dataIndex: 'client_id',
- key: 'client_id',
- render: (id) => {
- if (!id) return '-';
- return id.length > 20 ? `${id.substring(0, 20)}...` : id;
- },
- },
- {
- title: t('操作'),
- key: 'actions',
- render: (_, record) => (
- <Space>
- <Button
- icon={<IconEdit />}
- size="small"
- onClick={() => handleEdit(record)}
- >
- {t('编辑')}
- </Button>
- <Popconfirm
- title={t('确定要删除此 OAuth 提供商吗?')}
- onConfirm={() => handleDelete(record.id)}
- >
- <Button icon={<IconDelete />} size="small" type="danger">
- {t('删除')}
- </Button>
- </Popconfirm>
- </Space>
- ),
- },
- ];
- const discoveryAutoFilledLabels = (discoveryInfo?.autoFilledFields || [])
- .map((field) => DISCOVERY_FIELD_LABELS[field] || field)
- .join(', ');
- return (
- <Card>
- <Form.Section text={t('自定义 OAuth 提供商')}>
- <Banner
- type="info"
- description={
- <>
- {t(
- '配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商'
- )}
- <br />
- {t('回调 URL 格式')}: {serverAddress || t('网站地址')}/oauth/
- {'{slug}'}
- </>
- }
- style={{ marginBottom: 20 }}
- />
- <Button
- icon={<IconPlus />}
- theme="solid"
- onClick={handleAdd}
- style={{ marginBottom: 16 }}
- >
- {t('添加 OAuth 提供商')}
- </Button>
- <Table
- columns={columns}
- dataSource={providers}
- loading={loading}
- rowKey="id"
- pagination={false}
- empty={t('暂无自定义 OAuth 提供商')}
- />
- <Modal
- title={editingProvider ? t('编辑 OAuth 提供商') : t('添加 OAuth 提供商')}
- visible={modalVisible}
- 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={() => {
- setFormValues((prev) => ({ ...prev, ...getLatestFormValues() }));
- }}
- getFormApi={(api) => (formApiRef.current = api)}
- >
- <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
- field="name"
- label={t('显示名称')}
- placeholder={t('例如:GitHub Enterprise')}
- rules={[{ required: true, message: t('请输入显示名称') }]}
- />
- </Col>
- <Col span={12}>
- <Form.Input
- field="slug"
- label="Slug"
- placeholder={t('例如:github-enterprise')}
- extraText={t('URL 标识,只能包含小写字母、数字和连字符')}
- rules={[{ required: true, message: t('请输入 Slug') }]}
- />
- </Col>
- </Row>
- <Row gutter={16}>
- <Col span={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
- field="client_id"
- label="Client ID"
- placeholder={t('OAuth Client ID')}
- rules={[{ required: true, message: t('请输入 Client ID') }]}
- />
- </Col>
- <Col span={12}>
- <Form.Input
- field="client_secret"
- label="Client Secret"
- type="password"
- placeholder={
- editingProvider
- ? t('留空则保持原有密钥')
- : t('OAuth Client Secret')
- }
- rules={
- editingProvider
- ? []
- : [{ required: true, message: t('请输入 Client Secret') }]
- }
- />
- </Col>
- </Row>
- <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
- {t('OAuth 端点')}
- </Text>
- <Row gutter={16}>
- <Col span={24}>
- <Form.Input
- field="authorization_endpoint"
- label={t('Authorization Endpoint')}
- placeholder={
- selectedPreset && OAUTH_PRESETS[selectedPreset]
- ? t('填写 Issuer URL 后自动生成:') +
- OAUTH_PRESETS[selectedPreset].authorization_endpoint
- : 'https://example.com/oauth/authorize'
- }
- rules={[
- { required: true, message: t('请输入 Authorization Endpoint') },
- ]}
- />
- </Col>
- </Row>
- <Row gutter={16}>
- <Col span={12}>
- <Form.Input
- field="token_endpoint"
- label={t('Token Endpoint')}
- placeholder={
- selectedPreset && OAUTH_PRESETS[selectedPreset]
- ? t('自动生成:') + OAUTH_PRESETS[selectedPreset].token_endpoint
- : 'https://example.com/oauth/token'
- }
- rules={[{ required: true, message: t('请输入 Token Endpoint') }]}
- />
- </Col>
- <Col span={12}>
- <Form.Input
- field="user_info_endpoint"
- label={t('User Info Endpoint')}
- placeholder={
- selectedPreset && OAUTH_PRESETS[selectedPreset]
- ? t('自动生成:') + OAUTH_PRESETS[selectedPreset].user_info_endpoint
- : 'https://example.com/api/user'
- }
- rules={[
- { required: true, message: t('请输入 User Info Endpoint') },
- ]}
- />
- </Col>
- </Row>
- <Row gutter={16}>
- <Col span={12}>
- <Form.Input
- field="scopes"
- label={t('Scopes(可选)')}
- placeholder="openid profile email"
- extraText={
- discoveryInfo?.scopesSupported?.length
- ? t('Discovery 建议 scopes:') +
- discoveryInfo.scopesSupported.join(', ')
- : t('可手动填写,多个 scope 用空格分隔')
- }
- />
- </Col>
- </Row>
- <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
- {t('字段映射')}
- </Text>
- <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
- {t('配置如何从用户信息 API 响应中提取用户数据,支持 JSONPath 语法')}
- </Text>
- <Row gutter={16}>
- <Col span={12}>
- <Form.Input
- field="user_id_field"
- label={t('用户 ID 字段(可选)')}
- placeholder={t('例如:sub、id、data.user.id')}
- extraText={t('用于唯一标识用户的字段路径')}
- />
- </Col>
- <Col span={12}>
- <Form.Input
- field="username_field"
- label={t('用户名字段(可选)')}
- placeholder={t('例如:preferred_username、login')}
- />
- </Col>
- </Row>
- <Row gutter={16}>
- <Col span={12}>
- <Form.Input
- field="display_name_field"
- label={t('显示名称字段(可选)')}
- placeholder={t('例如:name、full_name')}
- />
- </Col>
- <Col span={12}>
- <Form.Input
- field="email_field"
- label={t('邮箱字段(可选)')}
- placeholder={t('例如:email')}
- />
- </Col>
- </Row>
- <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>
- <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>
- </Card>
- );
- };
- export default CustomOAuthSetting;
|