| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631 |
- /*
- 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,
- Table,
- Tag,
- Popconfirm,
- Space,
- Select,
- } from '@douyinfe/semi-ui';
- import { IconPlus, IconEdit, IconDelete } from '@douyinfe/semi-icons';
- import { API, showError, showSuccess } 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 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 formApiRef = React.useRef(null);
- 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,
- scopes: 'openid profile email',
- user_id_field: 'sub',
- username_field: 'preferred_username',
- display_name_field: 'name',
- email_field: 'email',
- auth_style: 0,
- });
- setSelectedPreset('');
- setBaseUrl('');
- setModalVisible(true);
- };
- const handleEdit = (provider) => {
- setEditingProvider(provider);
- setFormValues({ ...provider });
- setSelectedPreset('');
- setBaseUrl('');
- 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 () => {
- // 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 (!formValues[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 = formValues[field];
- if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
- // Check if user selected a preset but forgot to fill server address
- if (selectedPreset && !baseUrl) {
- showError(t('请先填写服务器地址,以自动生成完整的端点 URL'));
- } else {
- showError(t('端点 URL 必须是完整地址(以 http:// 或 https:// 开头)'));
- }
- return;
- }
- }
- try {
- let res;
- if (editingProvider) {
- res = await API.put(
- `/api/custom-oauth-provider/${editingProvider.id}`,
- formValues
- );
- } else {
- res = await API.post('/api/custom-oauth-provider/', formValues);
- }
- if (res.data.success) {
- showSuccess(editingProvider ? t('更新成功') : t('创建成功'));
- setModalVisible(false);
- fetchProviders();
- } else {
- showError(res.data.message);
- }
- } catch (error) {
- showError(editingProvider ? t('更新失败') : t('创建失败'));
- }
- };
- const handlePresetChange = (preset) => {
- setSelectedPreset(preset);
- if (preset && OAUTH_PRESETS[preset]) {
- const presetConfig = OAUTH_PRESETS[preset];
- const cleanUrl = baseUrl ? baseUrl.replace(/\/+$/, '') : '';
- const newValues = {
- name: presetConfig.name,
- slug: preset,
- scopes: presetConfig.scopes,
- user_id_field: presetConfig.user_id_field,
- username_field: presetConfig.username_field,
- display_name_field: presetConfig.display_name_field,
- email_field: presetConfig.email_field,
- auth_style: presetConfig.auth_style ?? 0,
- };
- // 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);
- });
- }
- }
- };
- const handleBaseUrlChange = (url) => {
- setBaseUrl(url);
- if (url && selectedPreset && OAUTH_PRESETS[selectedPreset]) {
- const presetConfig = OAUTH_PRESETS[selectedPreset];
- const cleanUrl = url.replace(/\/+$/, ''); // Remove trailing slashes
- 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);
- });
- }
- }
- };
- const columns = [
- {
- 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) => (id ? id.substring(0, 20) + '...' : '-'),
- },
- {
- 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>
- ),
- },
- ];
- 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}
- onOk={handleSubmit}
- onCancel={() => setModalVisible(false)}
- okText={t('保存')}
- cancelText={t('取消')}
- width={800}
- >
- <Form
- initValues={formValues}
- onValueChange={(values) => setFormValues(values)}
- 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>
- )}
- <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={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('填写服务器地址后自动生成:') +
- 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"
- />
- </Col>
- <Col span={12}>
- <Form.Input
- field="well_known"
- label={t('Well-Known URL')}
- placeholder={t('OIDC Discovery 端点(可选)')}
- />
- </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>
- <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
- {t('高级选项')}
- </Text>
- <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>
- </Form>
- </Modal>
- </Form.Section>
- </Card>
- );
- };
- export default CustomOAuthSetting;
|