CustomOAuthSetting.jsx 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useEffect, useState } from 'react';
  16. import {
  17. Button,
  18. Form,
  19. Row,
  20. Col,
  21. Typography,
  22. Modal,
  23. Banner,
  24. Card,
  25. Collapse,
  26. Switch,
  27. Table,
  28. Tag,
  29. Popconfirm,
  30. Space,
  31. } from '@douyinfe/semi-ui';
  32. import {
  33. IconPlus,
  34. IconEdit,
  35. IconDelete,
  36. IconRefresh,
  37. } from '@douyinfe/semi-icons';
  38. import { API, showError, showSuccess, getOAuthProviderIcon } from '../../helpers';
  39. import { useTranslation } from 'react-i18next';
  40. const { Text } = Typography;
  41. // Preset templates for common OAuth providers
  42. const OAUTH_PRESETS = {
  43. 'github-enterprise': {
  44. name: 'GitHub Enterprise',
  45. authorization_endpoint: '/login/oauth/authorize',
  46. token_endpoint: '/login/oauth/access_token',
  47. user_info_endpoint: '/api/v3/user',
  48. scopes: 'user:email',
  49. user_id_field: 'id',
  50. username_field: 'login',
  51. display_name_field: 'name',
  52. email_field: 'email',
  53. },
  54. gitlab: {
  55. name: 'GitLab',
  56. authorization_endpoint: '/oauth/authorize',
  57. token_endpoint: '/oauth/token',
  58. user_info_endpoint: '/api/v4/user',
  59. scopes: 'openid profile email',
  60. user_id_field: 'id',
  61. username_field: 'username',
  62. display_name_field: 'name',
  63. email_field: 'email',
  64. },
  65. gitea: {
  66. name: 'Gitea',
  67. authorization_endpoint: '/login/oauth/authorize',
  68. token_endpoint: '/login/oauth/access_token',
  69. user_info_endpoint: '/api/v1/user',
  70. scopes: 'openid profile email',
  71. user_id_field: 'id',
  72. username_field: 'login',
  73. display_name_field: 'full_name',
  74. email_field: 'email',
  75. },
  76. nextcloud: {
  77. name: 'Nextcloud',
  78. authorization_endpoint: '/apps/oauth2/authorize',
  79. token_endpoint: '/apps/oauth2/api/v1/token',
  80. user_info_endpoint: '/ocs/v2.php/cloud/user?format=json',
  81. scopes: 'openid profile email',
  82. user_id_field: 'ocs.data.id',
  83. username_field: 'ocs.data.id',
  84. display_name_field: 'ocs.data.displayname',
  85. email_field: 'ocs.data.email',
  86. },
  87. keycloak: {
  88. name: 'Keycloak',
  89. authorization_endpoint: '/realms/{realm}/protocol/openid-connect/auth',
  90. token_endpoint: '/realms/{realm}/protocol/openid-connect/token',
  91. user_info_endpoint: '/realms/{realm}/protocol/openid-connect/userinfo',
  92. scopes: 'openid profile email',
  93. user_id_field: 'sub',
  94. username_field: 'preferred_username',
  95. display_name_field: 'name',
  96. email_field: 'email',
  97. },
  98. authentik: {
  99. name: 'Authentik',
  100. authorization_endpoint: '/application/o/authorize/',
  101. token_endpoint: '/application/o/token/',
  102. user_info_endpoint: '/application/o/userinfo/',
  103. scopes: 'openid profile email',
  104. user_id_field: 'sub',
  105. username_field: 'preferred_username',
  106. display_name_field: 'name',
  107. email_field: 'email',
  108. },
  109. ory: {
  110. name: 'ORY Hydra',
  111. authorization_endpoint: '/oauth2/auth',
  112. token_endpoint: '/oauth2/token',
  113. user_info_endpoint: '/userinfo',
  114. scopes: 'openid profile email',
  115. user_id_field: 'sub',
  116. username_field: 'preferred_username',
  117. display_name_field: 'name',
  118. email_field: 'email',
  119. },
  120. };
  121. const OAUTH_PRESET_ICONS = {
  122. 'github-enterprise': 'github',
  123. gitlab: 'gitlab',
  124. gitea: 'gitea',
  125. nextcloud: 'nextcloud',
  126. keycloak: 'keycloak',
  127. authentik: 'authentik',
  128. ory: 'openid',
  129. };
  130. const getPresetIcon = (preset) => OAUTH_PRESET_ICONS[preset] || '';
  131. const PRESET_RESET_VALUES = {
  132. name: '',
  133. slug: '',
  134. icon: '',
  135. authorization_endpoint: '',
  136. token_endpoint: '',
  137. user_info_endpoint: '',
  138. scopes: '',
  139. user_id_field: '',
  140. username_field: '',
  141. display_name_field: '',
  142. email_field: '',
  143. well_known: '',
  144. auth_style: 0,
  145. access_policy: '',
  146. access_denied_message: '',
  147. };
  148. const DISCOVERY_FIELD_LABELS = {
  149. authorization_endpoint: 'Authorization Endpoint',
  150. token_endpoint: 'Token Endpoint',
  151. user_info_endpoint: 'User Info Endpoint',
  152. scopes: 'Scopes',
  153. user_id_field: 'User ID Field',
  154. username_field: 'Username Field',
  155. display_name_field: 'Display Name Field',
  156. email_field: 'Email Field',
  157. };
  158. const ACCESS_POLICY_TEMPLATES = {
  159. level_active: `{
  160. "logic": "and",
  161. "conditions": [
  162. {"field": "trust_level", "op": "gte", "value": 2},
  163. {"field": "active", "op": "eq", "value": true}
  164. ]
  165. }`,
  166. org_or_role: `{
  167. "logic": "or",
  168. "conditions": [
  169. {"field": "org", "op": "eq", "value": "core"},
  170. {"field": "roles", "op": "contains", "value": "admin"}
  171. ]
  172. }`,
  173. };
  174. const ACCESS_DENIED_TEMPLATES = {
  175. level_hint: '需要等级 {{required}},你当前等级 {{current}}(字段:{{field}})',
  176. org_hint: '仅限指定组织或角色访问。组织={{current.org}},角色={{current.roles}}',
  177. };
  178. const CustomOAuthSetting = ({ serverAddress }) => {
  179. const { t } = useTranslation();
  180. const [providers, setProviders] = useState([]);
  181. const [loading, setLoading] = useState(false);
  182. const [modalVisible, setModalVisible] = useState(false);
  183. const [editingProvider, setEditingProvider] = useState(null);
  184. const [formValues, setFormValues] = useState({});
  185. const [selectedPreset, setSelectedPreset] = useState('');
  186. const [baseUrl, setBaseUrl] = useState('');
  187. const [discoveryLoading, setDiscoveryLoading] = useState(false);
  188. const [discoveryInfo, setDiscoveryInfo] = useState(null);
  189. const [advancedActiveKeys, setAdvancedActiveKeys] = useState([]);
  190. const formApiRef = React.useRef(null);
  191. const mergeFormValues = (newValues) => {
  192. setFormValues((prev) => ({ ...prev, ...newValues }));
  193. if (!formApiRef.current) return;
  194. Object.entries(newValues).forEach(([key, value]) => {
  195. formApiRef.current.setValue(key, value);
  196. });
  197. };
  198. const getLatestFormValues = () => {
  199. const values = formApiRef.current?.getValues?.();
  200. return values && typeof values === 'object' ? values : formValues;
  201. };
  202. const normalizeBaseUrl = (url) => (url || '').trim().replace(/\/+$/, '');
  203. const inferBaseUrlFromProvider = (provider) => {
  204. const endpoint = provider?.authorization_endpoint || provider?.token_endpoint;
  205. if (!endpoint) return '';
  206. try {
  207. const url = new URL(endpoint);
  208. return `${url.protocol}//${url.host}`;
  209. } catch (error) {
  210. return '';
  211. }
  212. };
  213. const resetDiscoveryState = () => {
  214. setDiscoveryInfo(null);
  215. };
  216. const closeModal = () => {
  217. setModalVisible(false);
  218. resetDiscoveryState();
  219. setAdvancedActiveKeys([]);
  220. };
  221. const fetchProviders = async () => {
  222. setLoading(true);
  223. try {
  224. const res = await API.get('/api/custom-oauth-provider/');
  225. if (res.data.success) {
  226. setProviders(res.data.data || []);
  227. } else {
  228. showError(res.data.message);
  229. }
  230. } catch (error) {
  231. showError(t('获取自定义 OAuth 提供商列表失败'));
  232. }
  233. setLoading(false);
  234. };
  235. useEffect(() => {
  236. fetchProviders();
  237. }, []);
  238. const handleAdd = () => {
  239. setEditingProvider(null);
  240. setFormValues({
  241. enabled: false,
  242. icon: '',
  243. scopes: 'openid profile email',
  244. user_id_field: 'sub',
  245. username_field: 'preferred_username',
  246. display_name_field: 'name',
  247. email_field: 'email',
  248. auth_style: 0,
  249. access_policy: '',
  250. access_denied_message: '',
  251. });
  252. setSelectedPreset('');
  253. setBaseUrl('');
  254. resetDiscoveryState();
  255. setAdvancedActiveKeys([]);
  256. setModalVisible(true);
  257. };
  258. const handleEdit = (provider) => {
  259. setEditingProvider(provider);
  260. setFormValues({ ...provider });
  261. setSelectedPreset(OAUTH_PRESETS[provider.slug] ? provider.slug : '');
  262. setBaseUrl(inferBaseUrlFromProvider(provider));
  263. resetDiscoveryState();
  264. setAdvancedActiveKeys([]);
  265. setModalVisible(true);
  266. };
  267. const handleDelete = async (id) => {
  268. try {
  269. const res = await API.delete(`/api/custom-oauth-provider/${id}`);
  270. if (res.data.success) {
  271. showSuccess(t('删除成功'));
  272. fetchProviders();
  273. } else {
  274. showError(res.data.message);
  275. }
  276. } catch (error) {
  277. showError(t('删除失败'));
  278. }
  279. };
  280. const handleSubmit = async () => {
  281. const currentValues = getLatestFormValues();
  282. // Validate required fields
  283. const requiredFields = [
  284. 'name',
  285. 'slug',
  286. 'client_id',
  287. 'authorization_endpoint',
  288. 'token_endpoint',
  289. 'user_info_endpoint',
  290. ];
  291. if (!editingProvider) {
  292. requiredFields.push('client_secret');
  293. }
  294. for (const field of requiredFields) {
  295. if (!currentValues[field]) {
  296. showError(t(`请填写 ${field}`));
  297. return;
  298. }
  299. }
  300. // Validate endpoint URLs must be full URLs
  301. const endpointFields = ['authorization_endpoint', 'token_endpoint', 'user_info_endpoint'];
  302. for (const field of endpointFields) {
  303. const value = currentValues[field];
  304. if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
  305. // Check if user selected a preset but forgot to fill issuer URL
  306. if (selectedPreset && !baseUrl) {
  307. showError(t('请先填写 Issuer URL,以自动生成完整的端点 URL'));
  308. } else {
  309. showError(t('端点 URL 必须是完整地址(以 http:// 或 https:// 开头)'));
  310. }
  311. return;
  312. }
  313. }
  314. try {
  315. const payload = { ...currentValues, enabled: !!currentValues.enabled };
  316. delete payload.preset;
  317. delete payload.base_url;
  318. let res;
  319. if (editingProvider) {
  320. res = await API.put(
  321. `/api/custom-oauth-provider/${editingProvider.id}`,
  322. payload
  323. );
  324. } else {
  325. res = await API.post('/api/custom-oauth-provider/', payload);
  326. }
  327. if (res.data.success) {
  328. showSuccess(editingProvider ? t('更新成功') : t('创建成功'));
  329. closeModal();
  330. fetchProviders();
  331. } else {
  332. showError(res.data.message);
  333. }
  334. } catch (error) {
  335. showError(
  336. error?.response?.data?.message ||
  337. (editingProvider ? t('更新失败') : t('创建失败')),
  338. );
  339. }
  340. };
  341. const handleFetchFromDiscovery = async () => {
  342. const cleanBaseUrl = normalizeBaseUrl(baseUrl);
  343. const configuredWellKnown = (formValues.well_known || '').trim();
  344. const wellKnownUrl =
  345. configuredWellKnown ||
  346. (cleanBaseUrl ? `${cleanBaseUrl}/.well-known/openid-configuration` : '');
  347. if (!wellKnownUrl) {
  348. showError(t('请先填写 Discovery URL 或 Issuer URL'));
  349. return;
  350. }
  351. setDiscoveryLoading(true);
  352. try {
  353. const res = await API.post('/api/custom-oauth-provider/discovery', {
  354. well_known_url: configuredWellKnown || '',
  355. issuer_url: cleanBaseUrl || '',
  356. });
  357. if (!res.data.success) {
  358. throw new Error(res.data.message || t('未知错误'));
  359. }
  360. const data = res.data.data?.discovery || {};
  361. const resolvedWellKnown = res.data.data?.well_known_url || wellKnownUrl;
  362. const discoveredValues = {
  363. well_known: resolvedWellKnown,
  364. };
  365. const autoFilledFields = [];
  366. if (data.authorization_endpoint) {
  367. discoveredValues.authorization_endpoint = data.authorization_endpoint;
  368. autoFilledFields.push('authorization_endpoint');
  369. }
  370. if (data.token_endpoint) {
  371. discoveredValues.token_endpoint = data.token_endpoint;
  372. autoFilledFields.push('token_endpoint');
  373. }
  374. if (data.userinfo_endpoint) {
  375. discoveredValues.user_info_endpoint = data.userinfo_endpoint;
  376. autoFilledFields.push('user_info_endpoint');
  377. }
  378. const scopesSupported = Array.isArray(data.scopes_supported)
  379. ? data.scopes_supported
  380. : [];
  381. if (scopesSupported.length > 0 && !formValues.scopes) {
  382. const preferredScopes = ['openid', 'profile', 'email'].filter((scope) =>
  383. scopesSupported.includes(scope),
  384. );
  385. discoveredValues.scopes =
  386. preferredScopes.length > 0
  387. ? preferredScopes.join(' ')
  388. : scopesSupported.slice(0, 5).join(' ');
  389. autoFilledFields.push('scopes');
  390. }
  391. const claimsSupported = Array.isArray(data.claims_supported)
  392. ? data.claims_supported
  393. : [];
  394. const claimMap = {
  395. user_id_field: 'sub',
  396. username_field: 'preferred_username',
  397. display_name_field: 'name',
  398. email_field: 'email',
  399. };
  400. Object.entries(claimMap).forEach(([field, claim]) => {
  401. if (!formValues[field] && claimsSupported.includes(claim)) {
  402. discoveredValues[field] = claim;
  403. autoFilledFields.push(field);
  404. }
  405. });
  406. const hasCoreEndpoint =
  407. discoveredValues.authorization_endpoint ||
  408. discoveredValues.token_endpoint ||
  409. discoveredValues.user_info_endpoint;
  410. if (!hasCoreEndpoint) {
  411. showError(t('未在 Discovery 响应中找到可用的 OAuth 端点'));
  412. return;
  413. }
  414. mergeFormValues(discoveredValues);
  415. setDiscoveryInfo({
  416. wellKnown: wellKnownUrl,
  417. autoFilledFields,
  418. scopesSupported: scopesSupported.slice(0, 12),
  419. claimsSupported: claimsSupported.slice(0, 12),
  420. });
  421. showSuccess(t('已从 Discovery 自动填充配置'));
  422. } catch (error) {
  423. showError(
  424. t('获取 Discovery 配置失败:') + (error?.message || t('未知错误')),
  425. );
  426. } finally {
  427. setDiscoveryLoading(false);
  428. }
  429. };
  430. const handlePresetChange = (preset) => {
  431. setSelectedPreset(preset);
  432. resetDiscoveryState();
  433. const cleanUrl = normalizeBaseUrl(baseUrl);
  434. if (!preset || !OAUTH_PRESETS[preset]) {
  435. mergeFormValues(PRESET_RESET_VALUES);
  436. return;
  437. }
  438. const presetConfig = OAUTH_PRESETS[preset];
  439. const newValues = {
  440. ...PRESET_RESET_VALUES,
  441. name: presetConfig.name,
  442. slug: preset,
  443. icon: getPresetIcon(preset),
  444. scopes: presetConfig.scopes,
  445. user_id_field: presetConfig.user_id_field,
  446. username_field: presetConfig.username_field,
  447. display_name_field: presetConfig.display_name_field,
  448. email_field: presetConfig.email_field,
  449. auth_style: presetConfig.auth_style ?? 0,
  450. };
  451. if (cleanUrl) {
  452. newValues.authorization_endpoint =
  453. cleanUrl + presetConfig.authorization_endpoint;
  454. newValues.token_endpoint = cleanUrl + presetConfig.token_endpoint;
  455. newValues.user_info_endpoint = cleanUrl + presetConfig.user_info_endpoint;
  456. }
  457. mergeFormValues(newValues);
  458. };
  459. const handleBaseUrlChange = (url) => {
  460. setBaseUrl(url);
  461. if (url && selectedPreset && OAUTH_PRESETS[selectedPreset]) {
  462. const presetConfig = OAUTH_PRESETS[selectedPreset];
  463. const cleanUrl = normalizeBaseUrl(url);
  464. const newValues = {
  465. authorization_endpoint: cleanUrl + presetConfig.authorization_endpoint,
  466. token_endpoint: cleanUrl + presetConfig.token_endpoint,
  467. user_info_endpoint: cleanUrl + presetConfig.user_info_endpoint,
  468. };
  469. mergeFormValues(newValues);
  470. }
  471. };
  472. const applyAccessPolicyTemplate = (templateKey) => {
  473. const template = ACCESS_POLICY_TEMPLATES[templateKey];
  474. if (!template) return;
  475. mergeFormValues({ access_policy: template });
  476. showSuccess(t('已填充策略模板'));
  477. };
  478. const applyDeniedTemplate = (templateKey) => {
  479. const template = ACCESS_DENIED_TEMPLATES[templateKey];
  480. if (!template) return;
  481. mergeFormValues({ access_denied_message: template });
  482. showSuccess(t('已填充提示模板'));
  483. };
  484. const columns = [
  485. {
  486. title: t('图标'),
  487. dataIndex: 'icon',
  488. key: 'icon',
  489. width: 80,
  490. render: (icon) => getOAuthProviderIcon(icon || '', 18),
  491. },
  492. {
  493. title: t('名称'),
  494. dataIndex: 'name',
  495. key: 'name',
  496. },
  497. {
  498. title: 'Slug',
  499. dataIndex: 'slug',
  500. key: 'slug',
  501. render: (slug) => <Tag>{slug}</Tag>,
  502. },
  503. {
  504. title: t('状态'),
  505. dataIndex: 'enabled',
  506. key: 'enabled',
  507. render: (enabled) => (
  508. <Tag color={enabled ? 'green' : 'grey'}>
  509. {enabled ? t('已启用') : t('已禁用')}
  510. </Tag>
  511. ),
  512. },
  513. {
  514. title: t('Client ID'),
  515. dataIndex: 'client_id',
  516. key: 'client_id',
  517. render: (id) => {
  518. if (!id) return '-';
  519. return id.length > 20 ? `${id.substring(0, 20)}...` : id;
  520. },
  521. },
  522. {
  523. title: t('操作'),
  524. key: 'actions',
  525. render: (_, record) => (
  526. <Space>
  527. <Button
  528. icon={<IconEdit />}
  529. size="small"
  530. onClick={() => handleEdit(record)}
  531. >
  532. {t('编辑')}
  533. </Button>
  534. <Popconfirm
  535. title={t('确定要删除此 OAuth 提供商吗?')}
  536. onConfirm={() => handleDelete(record.id)}
  537. >
  538. <Button icon={<IconDelete />} size="small" type="danger">
  539. {t('删除')}
  540. </Button>
  541. </Popconfirm>
  542. </Space>
  543. ),
  544. },
  545. ];
  546. const discoveryAutoFilledLabels = (discoveryInfo?.autoFilledFields || [])
  547. .map((field) => DISCOVERY_FIELD_LABELS[field] || field)
  548. .join(', ');
  549. return (
  550. <Card>
  551. <Form.Section text={t('自定义 OAuth 提供商')}>
  552. <Banner
  553. type="info"
  554. description={
  555. <>
  556. {t(
  557. '配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商'
  558. )}
  559. <br />
  560. {t('回调 URL 格式')}: {serverAddress || t('网站地址')}/oauth/
  561. {'{slug}'}
  562. </>
  563. }
  564. style={{ marginBottom: 20 }}
  565. />
  566. <Button
  567. icon={<IconPlus />}
  568. theme="solid"
  569. onClick={handleAdd}
  570. style={{ marginBottom: 16 }}
  571. >
  572. {t('添加 OAuth 提供商')}
  573. </Button>
  574. <Table
  575. columns={columns}
  576. dataSource={providers}
  577. loading={loading}
  578. rowKey="id"
  579. pagination={false}
  580. empty={t('暂无自定义 OAuth 提供商')}
  581. />
  582. <Modal
  583. title={editingProvider ? t('编辑 OAuth 提供商') : t('添加 OAuth 提供商')}
  584. visible={modalVisible}
  585. onCancel={closeModal}
  586. width={860}
  587. centered
  588. bodyStyle={{ maxHeight: '72vh', overflowY: 'auto', paddingRight: 6 }}
  589. footer={
  590. <div
  591. style={{
  592. display: 'flex',
  593. justifyContent: 'flex-end',
  594. alignItems: 'center',
  595. gap: 12,
  596. flexWrap: 'wrap',
  597. }}
  598. >
  599. <Space spacing={8} align='center'>
  600. <Text type='secondary'>{t('启用供应商')}</Text>
  601. <Switch
  602. checked={!!formValues.enabled}
  603. size='large'
  604. onChange={(checked) => mergeFormValues({ enabled: !!checked })}
  605. />
  606. <Tag color={formValues.enabled ? 'green' : 'grey'}>
  607. {formValues.enabled ? t('已启用') : t('已禁用')}
  608. </Tag>
  609. </Space>
  610. <Button onClick={closeModal}>{t('取消')}</Button>
  611. <Button type='primary' onClick={handleSubmit}>
  612. {t('保存')}
  613. </Button>
  614. </div>
  615. }
  616. >
  617. <Form
  618. initValues={formValues}
  619. onValueChange={() => {
  620. setFormValues((prev) => ({ ...prev, ...getLatestFormValues() }));
  621. }}
  622. getFormApi={(api) => (formApiRef.current = api)}
  623. >
  624. <Text strong style={{ display: 'block', marginBottom: 8 }}>
  625. {t('Configuration')}
  626. </Text>
  627. <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
  628. {t('先填写配置,再自动填充 OAuth 端点,能显著减少手工输入')}
  629. </Text>
  630. {discoveryInfo && (
  631. <Banner
  632. type='success'
  633. closeIcon={null}
  634. style={{ marginBottom: 12 }}
  635. description={
  636. <div>
  637. <div>
  638. {t('已从 Discovery 获取配置,可继续手动修改所有字段。')}
  639. </div>
  640. {discoveryAutoFilledLabels ? (
  641. <div>
  642. {t('自动填充字段')}:
  643. {' '}
  644. {discoveryAutoFilledLabels}
  645. </div>
  646. ) : null}
  647. {discoveryInfo.scopesSupported?.length ? (
  648. <div>
  649. {t('Discovery scopes')}:
  650. {' '}
  651. {discoveryInfo.scopesSupported.join(', ')}
  652. </div>
  653. ) : null}
  654. {discoveryInfo.claimsSupported?.length ? (
  655. <div>
  656. {t('Discovery claims')}:
  657. {' '}
  658. {discoveryInfo.claimsSupported.join(', ')}
  659. </div>
  660. ) : null}
  661. </div>
  662. }
  663. />
  664. )}
  665. <Row gutter={16}>
  666. <Col span={8}>
  667. <Form.Select
  668. field="preset"
  669. label={t('预设模板')}
  670. placeholder={t('选择预设模板(可选)')}
  671. value={selectedPreset}
  672. onChange={handlePresetChange}
  673. optionList={[
  674. { value: '', label: t('自定义') },
  675. ...Object.entries(OAUTH_PRESETS).map(([key, config]) => ({
  676. value: key,
  677. label: config.name,
  678. })),
  679. ]}
  680. />
  681. </Col>
  682. <Col span={10}>
  683. <Form.Input
  684. field="base_url"
  685. label={t('发行者 URL(Issuer URL)')}
  686. placeholder={t('例如:https://gitea.example.com')}
  687. value={baseUrl}
  688. onChange={handleBaseUrlChange}
  689. extraText={
  690. selectedPreset
  691. ? t('填写后会自动拼接预设端点')
  692. : t('可选:用于自动生成端点或 Discovery URL')
  693. }
  694. />
  695. </Col>
  696. <Col span={6}>
  697. <div style={{ display: 'flex', alignItems: 'flex-end', height: '100%' }}>
  698. <Button
  699. icon={<IconRefresh />}
  700. onClick={handleFetchFromDiscovery}
  701. loading={discoveryLoading}
  702. block
  703. >
  704. {t('获取 Discovery 配置')}
  705. </Button>
  706. </div>
  707. </Col>
  708. </Row>
  709. <Row gutter={16}>
  710. <Col span={24}>
  711. <Form.Input
  712. field="well_known"
  713. label={t('发现文档地址(Discovery URL,可选)')}
  714. placeholder={t('例如:https://example.com/.well-known/openid-configuration')}
  715. extraText={t('可留空;留空时会尝试使用 Issuer URL + /.well-known/openid-configuration')}
  716. />
  717. </Col>
  718. </Row>
  719. <Row gutter={16}>
  720. <Col span={12}>
  721. <Form.Input
  722. field="name"
  723. label={t('显示名称')}
  724. placeholder={t('例如:GitHub Enterprise')}
  725. rules={[{ required: true, message: t('请输入显示名称') }]}
  726. />
  727. </Col>
  728. <Col span={12}>
  729. <Form.Input
  730. field="slug"
  731. label="Slug"
  732. placeholder={t('例如:github-enterprise')}
  733. extraText={t('URL 标识,只能包含小写字母、数字和连字符')}
  734. rules={[{ required: true, message: t('请输入 Slug') }]}
  735. />
  736. </Col>
  737. </Row>
  738. <Row gutter={16}>
  739. <Col span={18}>
  740. <Form.Input
  741. field='icon'
  742. label={t('图标')}
  743. placeholder={t('例如:github / si:google / https://example.com/logo.png / 🐱')}
  744. extraText={
  745. <span>
  746. {t(
  747. '图标使用 react-icons(Simple Icons)或 URL/emoji,例如:github、gitlab、si:google',
  748. )}
  749. </span>
  750. }
  751. showClear
  752. />
  753. </Col>
  754. <Col span={6} style={{ display: 'flex', alignItems: 'flex-end' }}>
  755. <div
  756. style={{
  757. width: '100%',
  758. minHeight: 74,
  759. border: '1px solid var(--semi-color-border)',
  760. borderRadius: 8,
  761. display: 'flex',
  762. alignItems: 'center',
  763. justifyContent: 'center',
  764. marginBottom: 24,
  765. background: 'var(--semi-color-fill-0)',
  766. }}
  767. >
  768. {getOAuthProviderIcon(formValues.icon || '', 24)}
  769. </div>
  770. </Col>
  771. </Row>
  772. <Row gutter={16}>
  773. <Col span={12}>
  774. <Form.Input
  775. field="client_id"
  776. label="Client ID"
  777. placeholder={t('OAuth Client ID')}
  778. rules={[{ required: true, message: t('请输入 Client ID') }]}
  779. />
  780. </Col>
  781. <Col span={12}>
  782. <Form.Input
  783. field="client_secret"
  784. label="Client Secret"
  785. type="password"
  786. placeholder={
  787. editingProvider
  788. ? t('留空则保持原有密钥')
  789. : t('OAuth Client Secret')
  790. }
  791. rules={
  792. editingProvider
  793. ? []
  794. : [{ required: true, message: t('请输入 Client Secret') }]
  795. }
  796. />
  797. </Col>
  798. </Row>
  799. <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
  800. {t('OAuth 端点')}
  801. </Text>
  802. <Row gutter={16}>
  803. <Col span={24}>
  804. <Form.Input
  805. field="authorization_endpoint"
  806. label={t('Authorization Endpoint')}
  807. placeholder={
  808. selectedPreset && OAUTH_PRESETS[selectedPreset]
  809. ? t('填写 Issuer URL 后自动生成:') +
  810. OAUTH_PRESETS[selectedPreset].authorization_endpoint
  811. : 'https://example.com/oauth/authorize'
  812. }
  813. rules={[
  814. { required: true, message: t('请输入 Authorization Endpoint') },
  815. ]}
  816. />
  817. </Col>
  818. </Row>
  819. <Row gutter={16}>
  820. <Col span={12}>
  821. <Form.Input
  822. field="token_endpoint"
  823. label={t('Token Endpoint')}
  824. placeholder={
  825. selectedPreset && OAUTH_PRESETS[selectedPreset]
  826. ? t('自动生成:') + OAUTH_PRESETS[selectedPreset].token_endpoint
  827. : 'https://example.com/oauth/token'
  828. }
  829. rules={[{ required: true, message: t('请输入 Token Endpoint') }]}
  830. />
  831. </Col>
  832. <Col span={12}>
  833. <Form.Input
  834. field="user_info_endpoint"
  835. label={t('User Info Endpoint')}
  836. placeholder={
  837. selectedPreset && OAUTH_PRESETS[selectedPreset]
  838. ? t('自动生成:') + OAUTH_PRESETS[selectedPreset].user_info_endpoint
  839. : 'https://example.com/api/user'
  840. }
  841. rules={[
  842. { required: true, message: t('请输入 User Info Endpoint') },
  843. ]}
  844. />
  845. </Col>
  846. </Row>
  847. <Row gutter={16}>
  848. <Col span={12}>
  849. <Form.Input
  850. field="scopes"
  851. label={t('Scopes(可选)')}
  852. placeholder="openid profile email"
  853. extraText={
  854. discoveryInfo?.scopesSupported?.length
  855. ? t('Discovery 建议 scopes:') +
  856. discoveryInfo.scopesSupported.join(', ')
  857. : t('可手动填写,多个 scope 用空格分隔')
  858. }
  859. />
  860. </Col>
  861. </Row>
  862. <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
  863. {t('字段映射')}
  864. </Text>
  865. <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
  866. {t('配置如何从用户信息 API 响应中提取用户数据,支持 JSONPath 语法')}
  867. </Text>
  868. <Row gutter={16}>
  869. <Col span={12}>
  870. <Form.Input
  871. field="user_id_field"
  872. label={t('用户 ID 字段(可选)')}
  873. placeholder={t('例如:sub、id、data.user.id')}
  874. extraText={t('用于唯一标识用户的字段路径')}
  875. />
  876. </Col>
  877. <Col span={12}>
  878. <Form.Input
  879. field="username_field"
  880. label={t('用户名字段(可选)')}
  881. placeholder={t('例如:preferred_username、login')}
  882. />
  883. </Col>
  884. </Row>
  885. <Row gutter={16}>
  886. <Col span={12}>
  887. <Form.Input
  888. field="display_name_field"
  889. label={t('显示名称字段(可选)')}
  890. placeholder={t('例如:name、full_name')}
  891. />
  892. </Col>
  893. <Col span={12}>
  894. <Form.Input
  895. field="email_field"
  896. label={t('邮箱字段(可选)')}
  897. placeholder={t('例如:email')}
  898. />
  899. </Col>
  900. </Row>
  901. <Collapse
  902. keepDOM
  903. activeKey={advancedActiveKeys}
  904. style={{ marginTop: 16 }}
  905. onChange={(activeKey) => {
  906. const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
  907. setAdvancedActiveKeys(keys.filter(Boolean));
  908. }}
  909. >
  910. <Collapse.Panel header={t('高级选项')} itemKey='advanced'>
  911. <Row gutter={16}>
  912. <Col span={12}>
  913. <Form.Select
  914. field="auth_style"
  915. label={t('认证方式')}
  916. optionList={[
  917. { value: 0, label: t('自动检测') },
  918. { value: 1, label: t('POST 参数') },
  919. { value: 2, label: t('Basic Auth 头') },
  920. ]}
  921. />
  922. </Col>
  923. </Row>
  924. <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
  925. {t('准入策略')}
  926. </Text>
  927. <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
  928. {t('可选:基于用户信息 JSON 做组合条件准入,条件不满足时返回自定义提示')}
  929. </Text>
  930. <Row gutter={16}>
  931. <Col span={24}>
  932. <Form.TextArea
  933. field='access_policy'
  934. value={formValues.access_policy || ''}
  935. onChange={(value) => mergeFormValues({ access_policy: value })}
  936. label={t('准入策略 JSON(可选)')}
  937. rows={6}
  938. placeholder={`{
  939. "logic": "and",
  940. "conditions": [
  941. {"field": "trust_level", "op": "gte", "value": 2},
  942. {"field": "active", "op": "eq", "value": true}
  943. ]
  944. }`}
  945. extraText={t('支持逻辑 and/or 与嵌套 groups;操作符支持 eq/ne/gt/gte/lt/lte/in/not_in/contains/exists')}
  946. showClear
  947. />
  948. <Space spacing={8} style={{ marginTop: 8 }}>
  949. <Button size='small' theme='light' onClick={() => applyAccessPolicyTemplate('level_active')}>
  950. {t('填充模板:等级+激活')}
  951. </Button>
  952. <Button size='small' theme='light' onClick={() => applyAccessPolicyTemplate('org_or_role')}>
  953. {t('填充模板:组织或角色')}
  954. </Button>
  955. </Space>
  956. </Col>
  957. </Row>
  958. <Row gutter={16}>
  959. <Col span={24}>
  960. <Form.Input
  961. field='access_denied_message'
  962. value={formValues.access_denied_message || ''}
  963. onChange={(value) => mergeFormValues({ access_denied_message: value })}
  964. label={t('拒绝提示模板(可选)')}
  965. placeholder={t('例如:需要等级 {{required}},你当前等级 {{current}}')}
  966. extraText={t('可用变量:{{provider}} {{field}} {{op}} {{required}} {{current}} 以及 {{current.path}}')}
  967. showClear
  968. />
  969. <Space spacing={8} style={{ marginTop: 8 }}>
  970. <Button size='small' theme='light' onClick={() => applyDeniedTemplate('level_hint')}>
  971. {t('填充模板:等级提示')}
  972. </Button>
  973. <Button size='small' theme='light' onClick={() => applyDeniedTemplate('org_hint')}>
  974. {t('填充模板:组织提示')}
  975. </Button>
  976. </Space>
  977. </Col>
  978. </Row>
  979. </Collapse.Panel>
  980. </Collapse>
  981. </Form>
  982. </Modal>
  983. </Form.Section>
  984. </Card>
  985. );
  986. };
  987. export default CustomOAuthSetting;