CustomOAuthSetting.jsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  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. Table,
  26. Tag,
  27. Popconfirm,
  28. Space,
  29. Select,
  30. } from '@douyinfe/semi-ui';
  31. import { IconPlus, IconEdit, IconDelete } from '@douyinfe/semi-icons';
  32. import { API, showError, showSuccess } from '../../helpers';
  33. import { useTranslation } from 'react-i18next';
  34. const { Text } = Typography;
  35. // Preset templates for common OAuth providers
  36. const OAUTH_PRESETS = {
  37. 'github-enterprise': {
  38. name: 'GitHub Enterprise',
  39. authorization_endpoint: '/login/oauth/authorize',
  40. token_endpoint: '/login/oauth/access_token',
  41. user_info_endpoint: '/api/v3/user',
  42. scopes: 'user:email',
  43. user_id_field: 'id',
  44. username_field: 'login',
  45. display_name_field: 'name',
  46. email_field: 'email',
  47. },
  48. gitlab: {
  49. name: 'GitLab',
  50. authorization_endpoint: '/oauth/authorize',
  51. token_endpoint: '/oauth/token',
  52. user_info_endpoint: '/api/v4/user',
  53. scopes: 'openid profile email',
  54. user_id_field: 'id',
  55. username_field: 'username',
  56. display_name_field: 'name',
  57. email_field: 'email',
  58. },
  59. gitea: {
  60. name: 'Gitea',
  61. authorization_endpoint: '/login/oauth/authorize',
  62. token_endpoint: '/login/oauth/access_token',
  63. user_info_endpoint: '/api/v1/user',
  64. scopes: 'openid profile email',
  65. user_id_field: 'id',
  66. username_field: 'login',
  67. display_name_field: 'full_name',
  68. email_field: 'email',
  69. },
  70. nextcloud: {
  71. name: 'Nextcloud',
  72. authorization_endpoint: '/apps/oauth2/authorize',
  73. token_endpoint: '/apps/oauth2/api/v1/token',
  74. user_info_endpoint: '/ocs/v2.php/cloud/user?format=json',
  75. scopes: 'openid profile email',
  76. user_id_field: 'ocs.data.id',
  77. username_field: 'ocs.data.id',
  78. display_name_field: 'ocs.data.displayname',
  79. email_field: 'ocs.data.email',
  80. },
  81. keycloak: {
  82. name: 'Keycloak',
  83. authorization_endpoint: '/realms/{realm}/protocol/openid-connect/auth',
  84. token_endpoint: '/realms/{realm}/protocol/openid-connect/token',
  85. user_info_endpoint: '/realms/{realm}/protocol/openid-connect/userinfo',
  86. scopes: 'openid profile email',
  87. user_id_field: 'sub',
  88. username_field: 'preferred_username',
  89. display_name_field: 'name',
  90. email_field: 'email',
  91. },
  92. authentik: {
  93. name: 'Authentik',
  94. authorization_endpoint: '/application/o/authorize/',
  95. token_endpoint: '/application/o/token/',
  96. user_info_endpoint: '/application/o/userinfo/',
  97. scopes: 'openid profile email',
  98. user_id_field: 'sub',
  99. username_field: 'preferred_username',
  100. display_name_field: 'name',
  101. email_field: 'email',
  102. },
  103. ory: {
  104. name: 'ORY Hydra',
  105. authorization_endpoint: '/oauth2/auth',
  106. token_endpoint: '/oauth2/token',
  107. user_info_endpoint: '/userinfo',
  108. scopes: 'openid profile email',
  109. user_id_field: 'sub',
  110. username_field: 'preferred_username',
  111. display_name_field: 'name',
  112. email_field: 'email',
  113. },
  114. };
  115. const CustomOAuthSetting = ({ serverAddress }) => {
  116. const { t } = useTranslation();
  117. const [providers, setProviders] = useState([]);
  118. const [loading, setLoading] = useState(false);
  119. const [modalVisible, setModalVisible] = useState(false);
  120. const [editingProvider, setEditingProvider] = useState(null);
  121. const [formValues, setFormValues] = useState({});
  122. const [selectedPreset, setSelectedPreset] = useState('');
  123. const [baseUrl, setBaseUrl] = useState('');
  124. const formApiRef = React.useRef(null);
  125. const fetchProviders = async () => {
  126. setLoading(true);
  127. try {
  128. const res = await API.get('/api/custom-oauth-provider/');
  129. if (res.data.success) {
  130. setProviders(res.data.data || []);
  131. } else {
  132. showError(res.data.message);
  133. }
  134. } catch (error) {
  135. showError(t('获取自定义 OAuth 提供商列表失败'));
  136. }
  137. setLoading(false);
  138. };
  139. useEffect(() => {
  140. fetchProviders();
  141. }, []);
  142. const handleAdd = () => {
  143. setEditingProvider(null);
  144. setFormValues({
  145. enabled: false,
  146. scopes: 'openid profile email',
  147. user_id_field: 'sub',
  148. username_field: 'preferred_username',
  149. display_name_field: 'name',
  150. email_field: 'email',
  151. auth_style: 0,
  152. });
  153. setSelectedPreset('');
  154. setBaseUrl('');
  155. setModalVisible(true);
  156. };
  157. const handleEdit = (provider) => {
  158. setEditingProvider(provider);
  159. setFormValues({ ...provider });
  160. setSelectedPreset('');
  161. setBaseUrl('');
  162. setModalVisible(true);
  163. };
  164. const handleDelete = async (id) => {
  165. try {
  166. const res = await API.delete(`/api/custom-oauth-provider/${id}`);
  167. if (res.data.success) {
  168. showSuccess(t('删除成功'));
  169. fetchProviders();
  170. } else {
  171. showError(res.data.message);
  172. }
  173. } catch (error) {
  174. showError(t('删除失败'));
  175. }
  176. };
  177. const handleSubmit = async () => {
  178. // Validate required fields
  179. const requiredFields = [
  180. 'name',
  181. 'slug',
  182. 'client_id',
  183. 'authorization_endpoint',
  184. 'token_endpoint',
  185. 'user_info_endpoint',
  186. ];
  187. if (!editingProvider) {
  188. requiredFields.push('client_secret');
  189. }
  190. for (const field of requiredFields) {
  191. if (!formValues[field]) {
  192. showError(t(`请填写 ${field}`));
  193. return;
  194. }
  195. }
  196. // Validate endpoint URLs must be full URLs
  197. const endpointFields = ['authorization_endpoint', 'token_endpoint', 'user_info_endpoint'];
  198. for (const field of endpointFields) {
  199. const value = formValues[field];
  200. if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
  201. // Check if user selected a preset but forgot to fill server address
  202. if (selectedPreset && !baseUrl) {
  203. showError(t('请先填写服务器地址,以自动生成完整的端点 URL'));
  204. } else {
  205. showError(t('端点 URL 必须是完整地址(以 http:// 或 https:// 开头)'));
  206. }
  207. return;
  208. }
  209. }
  210. try {
  211. let res;
  212. if (editingProvider) {
  213. res = await API.put(
  214. `/api/custom-oauth-provider/${editingProvider.id}`,
  215. formValues
  216. );
  217. } else {
  218. res = await API.post('/api/custom-oauth-provider/', formValues);
  219. }
  220. if (res.data.success) {
  221. showSuccess(editingProvider ? t('更新成功') : t('创建成功'));
  222. setModalVisible(false);
  223. fetchProviders();
  224. } else {
  225. showError(res.data.message);
  226. }
  227. } catch (error) {
  228. showError(editingProvider ? t('更新失败') : t('创建失败'));
  229. }
  230. };
  231. const handlePresetChange = (preset) => {
  232. setSelectedPreset(preset);
  233. if (preset && OAUTH_PRESETS[preset]) {
  234. const presetConfig = OAUTH_PRESETS[preset];
  235. const cleanUrl = baseUrl ? baseUrl.replace(/\/+$/, '') : '';
  236. const newValues = {
  237. name: presetConfig.name,
  238. slug: preset,
  239. scopes: presetConfig.scopes,
  240. user_id_field: presetConfig.user_id_field,
  241. username_field: presetConfig.username_field,
  242. display_name_field: presetConfig.display_name_field,
  243. email_field: presetConfig.email_field,
  244. auth_style: presetConfig.auth_style ?? 0,
  245. };
  246. // Only fill endpoints if server address is provided
  247. if (cleanUrl) {
  248. newValues.authorization_endpoint = cleanUrl + presetConfig.authorization_endpoint;
  249. newValues.token_endpoint = cleanUrl + presetConfig.token_endpoint;
  250. newValues.user_info_endpoint = cleanUrl + presetConfig.user_info_endpoint;
  251. }
  252. setFormValues((prev) => ({ ...prev, ...newValues }));
  253. // Update form fields directly via formApi
  254. if (formApiRef.current) {
  255. Object.entries(newValues).forEach(([key, value]) => {
  256. formApiRef.current.setValue(key, value);
  257. });
  258. }
  259. }
  260. };
  261. const handleBaseUrlChange = (url) => {
  262. setBaseUrl(url);
  263. if (url && selectedPreset && OAUTH_PRESETS[selectedPreset]) {
  264. const presetConfig = OAUTH_PRESETS[selectedPreset];
  265. const cleanUrl = url.replace(/\/+$/, ''); // Remove trailing slashes
  266. const newValues = {
  267. authorization_endpoint: cleanUrl + presetConfig.authorization_endpoint,
  268. token_endpoint: cleanUrl + presetConfig.token_endpoint,
  269. user_info_endpoint: cleanUrl + presetConfig.user_info_endpoint,
  270. };
  271. setFormValues((prev) => ({ ...prev, ...newValues }));
  272. // Update form fields directly via formApi (use merge mode to preserve other fields)
  273. if (formApiRef.current) {
  274. Object.entries(newValues).forEach(([key, value]) => {
  275. formApiRef.current.setValue(key, value);
  276. });
  277. }
  278. }
  279. };
  280. const columns = [
  281. {
  282. title: t('名称'),
  283. dataIndex: 'name',
  284. key: 'name',
  285. },
  286. {
  287. title: 'Slug',
  288. dataIndex: 'slug',
  289. key: 'slug',
  290. render: (slug) => <Tag>{slug}</Tag>,
  291. },
  292. {
  293. title: t('状态'),
  294. dataIndex: 'enabled',
  295. key: 'enabled',
  296. render: (enabled) => (
  297. <Tag color={enabled ? 'green' : 'grey'}>
  298. {enabled ? t('已启用') : t('已禁用')}
  299. </Tag>
  300. ),
  301. },
  302. {
  303. title: t('Client ID'),
  304. dataIndex: 'client_id',
  305. key: 'client_id',
  306. render: (id) => (id ? id.substring(0, 20) + '...' : '-'),
  307. },
  308. {
  309. title: t('操作'),
  310. key: 'actions',
  311. render: (_, record) => (
  312. <Space>
  313. <Button
  314. icon={<IconEdit />}
  315. size="small"
  316. onClick={() => handleEdit(record)}
  317. >
  318. {t('编辑')}
  319. </Button>
  320. <Popconfirm
  321. title={t('确定要删除此 OAuth 提供商吗?')}
  322. onConfirm={() => handleDelete(record.id)}
  323. >
  324. <Button icon={<IconDelete />} size="small" type="danger">
  325. {t('删除')}
  326. </Button>
  327. </Popconfirm>
  328. </Space>
  329. ),
  330. },
  331. ];
  332. return (
  333. <Card>
  334. <Form.Section text={t('自定义 OAuth 提供商')}>
  335. <Banner
  336. type="info"
  337. description={
  338. <>
  339. {t(
  340. '配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商'
  341. )}
  342. <br />
  343. {t('回调 URL 格式')}: {serverAddress || t('网站地址')}/oauth/
  344. {'{slug}'}
  345. </>
  346. }
  347. style={{ marginBottom: 20 }}
  348. />
  349. <Button
  350. icon={<IconPlus />}
  351. theme="solid"
  352. onClick={handleAdd}
  353. style={{ marginBottom: 16 }}
  354. >
  355. {t('添加 OAuth 提供商')}
  356. </Button>
  357. <Table
  358. columns={columns}
  359. dataSource={providers}
  360. loading={loading}
  361. rowKey="id"
  362. pagination={false}
  363. empty={t('暂无自定义 OAuth 提供商')}
  364. />
  365. <Modal
  366. title={editingProvider ? t('编辑 OAuth 提供商') : t('添加 OAuth 提供商')}
  367. visible={modalVisible}
  368. onOk={handleSubmit}
  369. onCancel={() => setModalVisible(false)}
  370. okText={t('保存')}
  371. cancelText={t('取消')}
  372. width={800}
  373. >
  374. <Form
  375. initValues={formValues}
  376. onValueChange={(values) => setFormValues(values)}
  377. getFormApi={(api) => (formApiRef.current = api)}
  378. >
  379. {!editingProvider && (
  380. <Row gutter={16} style={{ marginBottom: 16 }}>
  381. <Col span={12}>
  382. <Form.Select
  383. field="preset"
  384. label={t('预设模板')}
  385. placeholder={t('选择预设模板(可选)')}
  386. value={selectedPreset}
  387. onChange={handlePresetChange}
  388. optionList={[
  389. { value: '', label: t('自定义') },
  390. ...Object.entries(OAUTH_PRESETS).map(([key, config]) => ({
  391. value: key,
  392. label: config.name,
  393. })),
  394. ]}
  395. />
  396. </Col>
  397. <Col span={12}>
  398. <Form.Input
  399. field="base_url"
  400. label={
  401. selectedPreset
  402. ? t('服务器地址') + ' *'
  403. : t('服务器地址')
  404. }
  405. placeholder={t('例如:https://gitea.example.com')}
  406. value={baseUrl}
  407. onChange={handleBaseUrlChange}
  408. extraText={
  409. selectedPreset
  410. ? t('必填:请输入服务器地址以自动生成完整端点 URL')
  411. : t('选择预设模板后填写服务器地址可自动填充端点')
  412. }
  413. />
  414. </Col>
  415. </Row>
  416. )}
  417. <Row gutter={16}>
  418. <Col span={12}>
  419. <Form.Input
  420. field="name"
  421. label={t('显示名称')}
  422. placeholder={t('例如:GitHub Enterprise')}
  423. rules={[{ required: true, message: t('请输入显示名称') }]}
  424. />
  425. </Col>
  426. <Col span={12}>
  427. <Form.Input
  428. field="slug"
  429. label="Slug"
  430. placeholder={t('例如:github-enterprise')}
  431. extraText={t('URL 标识,只能包含小写字母、数字和连字符')}
  432. rules={[{ required: true, message: t('请输入 Slug') }]}
  433. />
  434. </Col>
  435. </Row>
  436. <Row gutter={16}>
  437. <Col span={12}>
  438. <Form.Input
  439. field="client_id"
  440. label="Client ID"
  441. placeholder={t('OAuth Client ID')}
  442. rules={[{ required: true, message: t('请输入 Client ID') }]}
  443. />
  444. </Col>
  445. <Col span={12}>
  446. <Form.Input
  447. field="client_secret"
  448. label="Client Secret"
  449. type="password"
  450. placeholder={
  451. editingProvider
  452. ? t('留空则保持原有密钥')
  453. : t('OAuth Client Secret')
  454. }
  455. rules={
  456. editingProvider
  457. ? []
  458. : [{ required: true, message: t('请输入 Client Secret') }]
  459. }
  460. />
  461. </Col>
  462. </Row>
  463. <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
  464. {t('OAuth 端点')}
  465. </Text>
  466. <Row gutter={16}>
  467. <Col span={24}>
  468. <Form.Input
  469. field="authorization_endpoint"
  470. label={t('Authorization Endpoint')}
  471. placeholder={
  472. selectedPreset && OAUTH_PRESETS[selectedPreset]
  473. ? t('填写服务器地址后自动生成:') +
  474. OAUTH_PRESETS[selectedPreset].authorization_endpoint
  475. : 'https://example.com/oauth/authorize'
  476. }
  477. rules={[
  478. { required: true, message: t('请输入 Authorization Endpoint') },
  479. ]}
  480. />
  481. </Col>
  482. </Row>
  483. <Row gutter={16}>
  484. <Col span={12}>
  485. <Form.Input
  486. field="token_endpoint"
  487. label={t('Token Endpoint')}
  488. placeholder={
  489. selectedPreset && OAUTH_PRESETS[selectedPreset]
  490. ? t('自动生成:') + OAUTH_PRESETS[selectedPreset].token_endpoint
  491. : 'https://example.com/oauth/token'
  492. }
  493. rules={[{ required: true, message: t('请输入 Token Endpoint') }]}
  494. />
  495. </Col>
  496. <Col span={12}>
  497. <Form.Input
  498. field="user_info_endpoint"
  499. label={t('User Info Endpoint')}
  500. placeholder={
  501. selectedPreset && OAUTH_PRESETS[selectedPreset]
  502. ? t('自动生成:') + OAUTH_PRESETS[selectedPreset].user_info_endpoint
  503. : 'https://example.com/api/user'
  504. }
  505. rules={[
  506. { required: true, message: t('请输入 User Info Endpoint') },
  507. ]}
  508. />
  509. </Col>
  510. </Row>
  511. <Row gutter={16}>
  512. <Col span={12}>
  513. <Form.Input
  514. field="scopes"
  515. label={t('Scopes')}
  516. placeholder="openid profile email"
  517. />
  518. </Col>
  519. <Col span={12}>
  520. <Form.Input
  521. field="well_known"
  522. label={t('Well-Known URL')}
  523. placeholder={t('OIDC Discovery 端点(可选)')}
  524. />
  525. </Col>
  526. </Row>
  527. <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
  528. {t('字段映射')}
  529. </Text>
  530. <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
  531. {t('配置如何从用户信息 API 响应中提取用户数据,支持 JSONPath 语法')}
  532. </Text>
  533. <Row gutter={16}>
  534. <Col span={12}>
  535. <Form.Input
  536. field="user_id_field"
  537. label={t('用户 ID 字段')}
  538. placeholder={t('例如:sub、id、data.user.id')}
  539. extraText={t('用于唯一标识用户的字段路径')}
  540. />
  541. </Col>
  542. <Col span={12}>
  543. <Form.Input
  544. field="username_field"
  545. label={t('用户名字段')}
  546. placeholder={t('例如:preferred_username、login')}
  547. />
  548. </Col>
  549. </Row>
  550. <Row gutter={16}>
  551. <Col span={12}>
  552. <Form.Input
  553. field="display_name_field"
  554. label={t('显示名称字段')}
  555. placeholder={t('例如:name、full_name')}
  556. />
  557. </Col>
  558. <Col span={12}>
  559. <Form.Input
  560. field="email_field"
  561. label={t('邮箱字段')}
  562. placeholder={t('例如:email')}
  563. />
  564. </Col>
  565. </Row>
  566. <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
  567. {t('高级选项')}
  568. </Text>
  569. <Row gutter={16}>
  570. <Col span={12}>
  571. <Form.Select
  572. field="auth_style"
  573. label={t('认证方式')}
  574. optionList={[
  575. { value: 0, label: t('自动检测') },
  576. { value: 1, label: t('POST 参数') },
  577. { value: 2, label: t('Basic Auth 头') },
  578. ]}
  579. />
  580. </Col>
  581. <Col span={12}>
  582. <Form.Checkbox field="enabled" noLabel>
  583. {t('启用此 OAuth 提供商')}
  584. </Form.Checkbox>
  585. </Col>
  586. </Row>
  587. </Form>
  588. </Modal>
  589. </Form.Section>
  590. </Card>
  591. );
  592. };
  593. export default CustomOAuthSetting;