This commit is contained in:
Seefs
2025-09-08 12:09:26 +08:00
parent b7527eb80e
commit 91a0eb7031
22 changed files with 5001 additions and 11 deletions

View File

@@ -0,0 +1,318 @@
/*
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, { useState } from 'react';
import {
Modal,
Form,
Input,
Select,
TextArea,
Switch,
Space,
Typography,
Divider,
Tag,
Button,
} from '@douyinfe/semi-ui';
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
import { API, showError, showSuccess, showInfo } from '../../../helpers';
const { Text, Paragraph } = Typography;
const { Option } = Select;
const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
const [formApi, setFormApi] = useState(null);
const [loading, setLoading] = useState(false);
const [redirectUris, setRedirectUris] = useState(['']);
const [clientType, setClientType] = useState('confidential');
const [grantTypes, setGrantTypes] = useState(['client_credentials']);
// 处理提交
const handleSubmit = async (values) => {
setLoading(true);
try {
// 过滤空的重定向URI
const validRedirectUris = redirectUris.filter(uri => uri.trim());
const payload = {
...values,
client_type: clientType,
grant_types: grantTypes,
redirect_uris: validRedirectUris,
};
const res = await API.post('/api/oauth_clients/', payload);
const { success, message, client_id, client_secret } = res.data;
if (success) {
showSuccess('OAuth2客户端创建成功');
// 显示客户端信息
Modal.info({
title: '客户端创建成功',
content: (
<div>
<Paragraph>请妥善保存以下信息</Paragraph>
<div style={{ background: '#f8f9fa', padding: '16px', borderRadius: '6px' }}>
<div style={{ marginBottom: '12px' }}>
<Text strong>客户端ID</Text>
<br />
<Text code copyable style={{ fontFamily: 'monospace' }}>
{client_id}
</Text>
</div>
{client_secret && (
<div>
<Text strong>客户端密钥仅此一次显示</Text>
<br />
<Text code copyable style={{ fontFamily: 'monospace' }}>
{client_secret}
</Text>
</div>
)}
</div>
<Paragraph type="warning" style={{ marginTop: '12px' }}>
{client_secret
? '客户端密钥仅显示一次,请立即复制保存。'
: '公开客户端无需密钥。'
}
</Paragraph>
</div>
),
width: 600,
onOk: () => {
resetForm();
onSuccess();
}
});
} else {
showError(message);
}
} catch (error) {
showError('创建OAuth2客户端失败');
} finally {
setLoading(false);
}
};
// 重置表单
const resetForm = () => {
if (formApi) {
formApi.reset();
}
setClientType('confidential');
setGrantTypes(['client_credentials']);
setRedirectUris(['']);
};
// 处理取消
const handleCancel = () => {
resetForm();
onCancel();
};
// 添加重定向URI
const addRedirectUri = () => {
setRedirectUris([...redirectUris, '']);
};
// 删除重定向URI
const removeRedirectUri = (index) => {
setRedirectUris(redirectUris.filter((_, i) => i !== index));
};
// 更新重定向URI
const updateRedirectUri = (index, value) => {
const newUris = [...redirectUris];
newUris[index] = value;
setRedirectUris(newUris);
};
// 授权类型变化处理
const handleGrantTypesChange = (values) => {
setGrantTypes(values);
// 如果包含authorization_code但没有重定向URI则添加一个
if (values.includes('authorization_code') && redirectUris.length === 1 && !redirectUris[0]) {
setRedirectUris(['']);
}
};
return (
<Modal
title="创建OAuth2客户端"
visible={visible}
onCancel={handleCancel}
onOk={() => formApi?.submit()}
okText="创建"
cancelText="取消"
confirmLoading={loading}
width={600}
style={{ top: 50 }}
>
<Form
getFormApi={(api) => setFormApi(api)}
onSubmit={handleSubmit}
labelPosition="top"
>
{/* 基本信息 */}
<Form.Input
field="name"
label="客户端名称"
placeholder="输入客户端名称"
rules={[{ required: true, message: '请输入客户端名称' }]}
/>
<Form.TextArea
field="description"
label="描述"
placeholder="输入客户端描述"
rows={3}
/>
{/* 客户端类型 */}
<div>
<Text strong>客户端类型</Text>
<Paragraph type="tertiary" size="small" style={{ marginTop: 4, marginBottom: 8 }}>
选择适合您应用程序的客户端类型
</Paragraph>
<div style={{ display: 'flex', gap: '12px', marginBottom: 16 }}>
<div
onClick={() => setClientType('confidential')}
style={{
flex: 1,
padding: '12px',
border: `2px solid ${clientType === 'confidential' ? '#3370ff' : '#e4e6e9'}`,
borderRadius: '6px',
cursor: 'pointer',
background: clientType === 'confidential' ? '#f0f5ff' : '#fff'
}}
>
<Text strong>机密客户端Confidential</Text>
<Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
用于服务器端应用可以安全地存储客户端密钥
</Paragraph>
</div>
<div
onClick={() => setClientType('public')}
style={{
flex: 1,
padding: '12px',
border: `2px solid ${clientType === 'public' ? '#3370ff' : '#e4e6e9'}`,
borderRadius: '6px',
cursor: 'pointer',
background: clientType === 'public' ? '#f0f5ff' : '#fff'
}}
>
<Text strong>公开客户端Public</Text>
<Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
用于移动应用或单页应用无法安全存储密钥
</Paragraph>
</div>
</div>
</div>
{/* 授权类型 */}
<Form.Select
field="grant_types"
label="允许的授权类型"
multiple
value={grantTypes}
onChange={handleGrantTypesChange}
rules={[{ required: true, message: '请选择至少一种授权类型' }]}
>
<Option value="client_credentials">Client Credentials客户端凭证</Option>
<Option value="authorization_code">Authorization Code授权码</Option>
<Option value="refresh_token">Refresh Token刷新令牌</Option>
</Form.Select>
{/* Scope */}
<Form.Select
field="scopes"
label="允许的权限范围Scope"
multiple
defaultValue={['api:read']}
rules={[{ required: true, message: '请选择至少一个权限范围' }]}
>
<Option value="api:read">api:read读取API</Option>
<Option value="api:write">api:write写入API</Option>
<Option value="admin">admin管理员权限</Option>
</Form.Select>
{/* PKCE设置 */}
<Form.Switch
field="require_pkce"
label="强制PKCE验证"
defaultChecked={true}
/>
<Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
PKCEProof Key for Code Exchange可提高授权码流程的安全性
</Paragraph>
{/* 重定向URI */}
{grantTypes.includes('authorization_code') && (
<>
<Divider>重定向URI配置</Divider>
<div style={{ marginBottom: 16 }}>
<Text strong>重定向URI</Text>
<Paragraph type="tertiary" size="small">
用于授权码流程用户授权后将重定向到这些URI必须使用HTTPS本地开发可使用HTTP
</Paragraph>
<Space direction="vertical" style={{ width: '100%' }}>
{redirectUris.map((uri, index) => (
<Space key={index} style={{ width: '100%' }}>
<Input
placeholder="https://your-app.com/callback"
value={uri}
onChange={(value) => updateRedirectUri(index, value)}
style={{ flex: 1 }}
/>
{redirectUris.length > 1 && (
<Button
theme="borderless"
type="danger"
size="small"
icon={<IconDelete />}
onClick={() => removeRedirectUri(index)}
/>
)}
</Space>
))}
</Space>
<Button
theme="borderless"
type="primary"
size="small"
icon={<IconPlus />}
onClick={addRedirectUri}
style={{ marginTop: 8 }}
>
添加重定向URI
</Button>
</div>
</>
)}
</Form>
</Modal>
);
};
export default CreateOAuth2ClientModal;

View File

@@ -0,0 +1,306 @@
/*
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, { useState, useEffect } from 'react';
import {
Modal,
Form,
Input,
Select,
TextArea,
Switch,
Space,
Typography,
Divider,
Button,
} from '@douyinfe/semi-ui';
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
import { API, showError, showSuccess } from '../../../helpers';
const { Text, Paragraph } = Typography;
const { Option } = Select;
const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
const [formApi, setFormApi] = useState(null);
const [loading, setLoading] = useState(false);
const [redirectUris, setRedirectUris] = useState(['']);
const [grantTypes, setGrantTypes] = useState(['client_credentials']);
// 初始化表单数据
useEffect(() => {
if (client && visible) {
// 解析授权类型
let parsedGrantTypes = [];
if (typeof client.grant_types === 'string') {
parsedGrantTypes = client.grant_types.split(',');
} else if (Array.isArray(client.grant_types)) {
parsedGrantTypes = client.grant_types;
}
// 解析Scope
let parsedScopes = [];
if (typeof client.scopes === 'string') {
parsedScopes = client.scopes.split(',');
} else if (Array.isArray(client.scopes)) {
parsedScopes = client.scopes;
}
// 解析重定向URI
let parsedRedirectUris = [''];
if (client.redirect_uris) {
try {
const parsed = typeof client.redirect_uris === 'string'
? JSON.parse(client.redirect_uris)
: client.redirect_uris;
if (Array.isArray(parsed) && parsed.length > 0) {
parsedRedirectUris = parsed;
}
} catch (e) {
console.warn('Failed to parse redirect URIs:', e);
}
}
setGrantTypes(parsedGrantTypes);
setRedirectUris(parsedRedirectUris);
// 设置表单值
const formValues = {
id: client.id,
name: client.name,
description: client.description,
client_type: client.client_type,
grant_types: parsedGrantTypes,
scopes: parsedScopes,
require_pkce: client.require_pkce,
status: client.status,
};
if (formApi) {
formApi.setValues(formValues);
}
}
}, [client, visible, formApi]);
// 处理提交
const handleSubmit = async (values) => {
setLoading(true);
try {
// 过滤空的重定向URI
const validRedirectUris = redirectUris.filter(uri => uri.trim());
const payload = {
...values,
grant_types: grantTypes,
redirect_uris: validRedirectUris,
};
const res = await API.put('/api/oauth_clients/', payload);
const { success, message } = res.data;
if (success) {
showSuccess('OAuth2客户端更新成功');
onSuccess();
} else {
showError(message);
}
} catch (error) {
showError('更新OAuth2客户端失败');
} finally {
setLoading(false);
}
};
// 添加重定向URI
const addRedirectUri = () => {
setRedirectUris([...redirectUris, '']);
};
// 删除重定向URI
const removeRedirectUri = (index) => {
setRedirectUris(redirectUris.filter((_, i) => i !== index));
};
// 更新重定向URI
const updateRedirectUri = (index, value) => {
const newUris = [...redirectUris];
newUris[index] = value;
setRedirectUris(newUris);
};
// 授权类型变化处理
const handleGrantTypesChange = (values) => {
setGrantTypes(values);
// 如果包含authorization_code但没有重定向URI则添加一个
if (values.includes('authorization_code') && redirectUris.length === 1 && !redirectUris[0]) {
setRedirectUris(['']);
}
};
if (!client) return null;
return (
<Modal
title={`编辑OAuth2客户端 - ${client.name}`}
visible={visible}
onCancel={onCancel}
onOk={() => formApi?.submit()}
okText="保存"
cancelText="取消"
confirmLoading={loading}
width={600}
style={{ top: 50 }}
>
<Form
getFormApi={(api) => setFormApi(api)}
onSubmit={handleSubmit}
labelPosition="top"
>
{/* 客户端ID只读 */}
<Form.Input
field="id"
label="客户端ID"
disabled
style={{ backgroundColor: '#f8f9fa' }}
/>
{/* 基本信息 */}
<Form.Input
field="name"
label="客户端名称"
placeholder="输入客户端名称"
rules={[{ required: true, message: '请输入客户端名称' }]}
/>
<Form.TextArea
field="description"
label="描述"
placeholder="输入客户端描述"
rows={3}
/>
{/* 客户端类型(只读) */}
<Form.Select
field="client_type"
label="客户端类型"
disabled
style={{ backgroundColor: '#f8f9fa' }}
>
<Option value="confidential">机密客户端Confidential</Option>
<Option value="public">公开客户端Public</Option>
</Form.Select>
<Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
客户端类型创建后不可更改
</Paragraph>
{/* 授权类型 */}
<Form.Select
field="grant_types"
label="允许的授权类型"
multiple
value={grantTypes}
onChange={handleGrantTypesChange}
rules={[{ required: true, message: '请选择至少一种授权类型' }]}
>
<Option value="client_credentials">Client Credentials客户端凭证</Option>
<Option value="authorization_code">Authorization Code授权码</Option>
<Option value="refresh_token">Refresh Token刷新令牌</Option>
</Form.Select>
{/* Scope */}
<Form.Select
field="scopes"
label="允许的权限范围Scope"
multiple
rules={[{ required: true, message: '请选择至少一个权限范围' }]}
>
<Option value="api:read">api:read读取API</Option>
<Option value="api:write">api:write写入API</Option>
<Option value="admin">admin管理员权限</Option>
</Form.Select>
{/* PKCE设置 */}
<Form.Switch
field="require_pkce"
label="强制PKCE验证"
/>
<Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
PKCEProof Key for Code Exchange可提高授权码流程的安全性
</Paragraph>
{/* 状态 */}
<Form.Select
field="status"
label="状态"
rules={[{ required: true, message: '请选择状态' }]}
>
<Option value={1}>启用</Option>
<Option value={2}>禁用</Option>
</Form.Select>
{/* 重定向URI */}
{grantTypes.includes('authorization_code') && (
<>
<Divider>重定向URI配置</Divider>
<div style={{ marginBottom: 16 }}>
<Text strong>重定向URI</Text>
<Paragraph type="tertiary" size="small">
用于授权码流程用户授权后将重定向到这些URI必须使用HTTPS本地开发可使用HTTP
</Paragraph>
<Space direction="vertical" style={{ width: '100%' }}>
{redirectUris.map((uri, index) => (
<Space key={index} style={{ width: '100%' }}>
<Input
placeholder="https://your-app.com/callback"
value={uri}
onChange={(value) => updateRedirectUri(index, value)}
style={{ flex: 1 }}
/>
{redirectUris.length > 1 && (
<Button
theme="borderless"
type="danger"
size="small"
icon={<IconDelete />}
onClick={() => removeRedirectUri(index)}
/>
)}
</Space>
))}
</Space>
<Button
theme="borderless"
type="primary"
size="small"
icon={<IconPlus />}
onClick={addRedirectUri}
style={{ marginTop: 8 }}
>
添加重定向URI
</Button>
</div>
</>
)}
</Form>
</Modal>
);
};
export default EditOAuth2ClientModal;

View File

@@ -0,0 +1,101 @@
/*
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 { Card, Spin } from '@douyinfe/semi-ui';
import { API, showError, toBoolean } from '../../helpers';
import OAuth2ServerSettings from '../../pages/Setting/OAuth2/OAuth2ServerSettings';
import OAuth2ClientSettings from '../../pages/Setting/OAuth2/OAuth2ClientSettings';
const OAuth2Setting = () => {
const [inputs, setInputs] = useState({
'oauth2.enabled': false,
'oauth2.issuer': '',
'oauth2.access_token_ttl': 10,
'oauth2.refresh_token_ttl': 720,
'oauth2.jwt_signing_algorithm': 'RS256',
'oauth2.jwt_key_id': 'oauth2-key-1',
'oauth2.jwt_private_key_file': '',
'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code'],
'oauth2.require_pkce': true,
'oauth2.auto_create_user': false,
'oauth2.default_user_role': 1,
'oauth2.default_user_group': 'default',
});
const [loading, setLoading] = useState(false);
const getOptions = async () => {
setLoading(true);
try {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (Object.keys(inputs).includes(item.key)) {
if (item.key === 'oauth2.allowed_grant_types') {
try {
newInputs[item.key] = JSON.parse(item.value || '["client_credentials","authorization_code"]');
} catch {
newInputs[item.key] = ['client_credentials', 'authorization_code'];
}
} else if (typeof inputs[item.key] === 'boolean') {
newInputs[item.key] = toBoolean(item.value);
} else if (typeof inputs[item.key] === 'number') {
newInputs[item.key] = parseInt(item.value) || inputs[item.key];
} else {
newInputs[item.key] = item.value;
}
}
});
setInputs({...inputs, ...newInputs});
} else {
showError(message);
}
} catch (error) {
showError('获取OAuth2设置失败');
} finally {
setLoading(false);
}
};
const refresh = () => {
getOptions();
};
useEffect(() => {
getOptions();
}, []);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '10px',
marginTop: '10px',
}}
>
<OAuth2ServerSettings options={inputs} refresh={refresh} />
<OAuth2ClientSettings />
</div>
);
};
export default OAuth2Setting;

View File

@@ -0,0 +1,420 @@
/*
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 {
Card,
Table,
Button,
Space,
Tag,
Typography,
Input,
Popconfirm,
Modal,
Form,
Banner,
Row,
Col
} from '@douyinfe/semi-ui';
import { IconSearch, IconPlus } from '@douyinfe/semi-icons';
import { API, showError, showSuccess, showInfo } from '../../../helpers';
import CreateOAuth2ClientModal from '../../../components/modals/oauth2/CreateOAuth2ClientModal';
import EditOAuth2ClientModal from '../../../components/modals/oauth2/EditOAuth2ClientModal';
import { useTranslation } from 'react-i18next';
const { Text, Title } = Typography;
export default function OAuth2ClientSettings() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [clients, setClients] = useState([]);
const [filteredClients, setFilteredClients] = useState([]);
const [searchKeyword, setSearchKeyword] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingClient, setEditingClient] = useState(null);
const [showSecretModal, setShowSecretModal] = useState(false);
const [currentSecret, setCurrentSecret] = useState('');
// 加载客户端列表
const loadClients = async () => {
setLoading(true);
try {
const res = await API.get('/api/oauth_clients/');
if (res.data.success) {
setClients(res.data.data || []);
setFilteredClients(res.data.data || []);
} else {
showError(res.data.message);
}
} catch (error) {
showError('加载OAuth2客户端失败');
} finally {
setLoading(false);
}
};
// 搜索过滤
const handleSearch = (value) => {
setSearchKeyword(value);
if (!value) {
setFilteredClients(clients);
} else {
const filtered = clients.filter(client =>
client.name?.toLowerCase().includes(value.toLowerCase()) ||
client.id?.toLowerCase().includes(value.toLowerCase()) ||
client.description?.toLowerCase().includes(value.toLowerCase())
);
setFilteredClients(filtered);
}
};
// 删除客户端
const handleDelete = async (client) => {
try {
const res = await API.delete(`/api/oauth_clients/${client.id}`);
if (res.data.success) {
showSuccess('删除成功');
loadClients();
} else {
showError(res.data.message);
}
} catch (error) {
showError('删除失败');
}
};
// 重新生成密钥
const handleRegenerateSecret = async (client) => {
try {
const res = await API.post(`/api/oauth_clients/${client.id}/regenerate_secret`);
if (res.data.success) {
setCurrentSecret(res.data.client_secret);
setShowSecretModal(true);
loadClients();
} else {
showError(res.data.message);
}
} catch (error) {
showError('重新生成密钥失败');
}
};
// 表格列定义
const columns = [
{
title: '客户端名称',
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<div>
<Text strong>{text}</Text>
<br />
<Text type="tertiary" size="small">{record.id}</Text>
</div>
),
},
{
title: '类型',
dataIndex: 'client_type',
key: 'client_type',
render: (text) => (
<Tag color={text === 'confidential' ? 'blue' : 'green'}>
{text === 'confidential' ? '机密客户端' : '公开客户端'}
</Tag>
),
},
{
title: '授权类型',
dataIndex: 'grant_types',
key: 'grant_types',
render: (grantTypes) => {
const types = typeof grantTypes === 'string' ? grantTypes.split(',') : (grantTypes || []);
return (
<div>
{types.map(type => (
<Tag key={type} size="small" style={{ margin: '2px' }}>
{type === 'client_credentials' ? '客户端凭证' :
type === 'authorization_code' ? '授权码' :
type === 'refresh_token' ? '刷新令牌' : type}
</Tag>
))}
</div>
);
},
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status) => (
<Tag color={status === 1 ? 'green' : 'red'}>
{status === 1 ? '启用' : '禁用'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_time',
key: 'created_time',
render: (time) => new Date(time * 1000).toLocaleString(),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space>
<Button
theme="borderless"
type="primary"
size="small"
onClick={() => {
setEditingClient(record);
setShowEditModal(true);
}}
>
编辑
</Button>
{record.client_type === 'confidential' && (
<Button
theme="borderless"
type="secondary"
size="small"
onClick={() => handleRegenerateSecret(record)}
>
重新生成密钥
</Button>
)}
<Popconfirm
title="确定删除这个OAuth2客户端吗"
content="删除后无法恢复相关的API访问将失效。"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button
theme="borderless"
type="danger"
size="small"
>
删除
</Button>
</Popconfirm>
</Space>
),
},
];
useEffect(() => {
loadClients();
}, []);
return (
<div>
<Card style={{ marginTop: 10 }}>
<Form.Section text={'OAuth2 客户端管理'}>
<Banner
type="info"
description="管理OAuth2客户端应用程序每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用公开客户端用于移动应用或单页应用。"
style={{ marginBottom: 15 }}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} style={{ marginBottom: 16 }}>
<Col xs={24} sm={24} md={12} lg={8} xl={8}>
<Input
prefix={<IconSearch />}
placeholder="搜索客户端名称、ID或描述"
value={searchKeyword}
onChange={handleSearch}
showClear
/>
</Col>
<Col xs={24} sm={24} md={12} lg={16} xl={16} style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={loadClients}>刷新</Button>
<Button
type="primary"
icon={<IconPlus />}
onClick={() => setShowCreateModal(true)}
>
创建OAuth2客户端
</Button>
</Col>
</Row>
<Table
columns={columns}
dataSource={filteredClients}
rowKey="id"
loading={loading}
pagination={{
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条,共 ${total}`,
pageSize: 10,
}}
empty={
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Text type="tertiary">暂无OAuth2客户端</Text>
<br />
<Button
type="primary"
icon={<IconPlus />}
onClick={() => setShowCreateModal(true)}
style={{ marginTop: 10 }}
>
创建第一个客户端
</Button>
</div>
}
/>
{/* 快速操作 */}
<div style={{ marginTop: 20, marginBottom: 10 }}>
<Text strong>快速操作</Text>
</div>
<div style={{ marginBottom: 20 }}>
<Space wrap>
<Button
type="tertiary"
onClick={async () => {
try {
const res = await API.get('/api/oauth/jwks');
Modal.info({
title: 'JWKS信息',
content: (
<div>
<Text>JSON Web Key Set:</Text>
<pre style={{
background: '#f8f9fa',
padding: '12px',
borderRadius: '4px',
marginTop: '8px',
fontSize: '12px',
maxHeight: '300px',
overflow: 'auto'
}}>
{JSON.stringify(res.data, null, 2)}
</pre>
</div>
),
width: 600
});
} catch (error) {
showError('获取JWKS失败');
}
}}
>
查看JWKS
</Button>
<Button
type="tertiary"
onClick={async () => {
try {
const res = await API.get('/api/oauth/server-info');
Modal.info({
title: 'OAuth2服务器信息',
content: (
<div>
<Text>授权服务器配置:</Text>
<pre style={{
background: '#f8f9fa',
padding: '12px',
borderRadius: '4px',
marginTop: '8px',
fontSize: '12px',
maxHeight: '300px',
overflow: 'auto'
}}>
{JSON.stringify(res.data, null, 2)}
</pre>
</div>
),
width: 600
});
} catch (error) {
showError('获取服务器信息失败');
}
}}
>
查看服务器信息
</Button>
<Button
type="tertiary"
onClick={() => showInfo('OAuth2集成文档功能开发中请参考相关API文档')}
>
集成文档
</Button>
</Space>
</div>
</Form.Section>
</Card>
{/* 创建客户端模态框 */}
<CreateOAuth2ClientModal
visible={showCreateModal}
onCancel={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
loadClients();
}}
/>
{/* 编辑客户端模态框 */}
<EditOAuth2ClientModal
visible={showEditModal}
client={editingClient}
onCancel={() => {
setShowEditModal(false);
setEditingClient(null);
}}
onSuccess={() => {
setShowEditModal(false);
setEditingClient(null);
loadClients();
}}
/>
{/* 密钥显示模态框 */}
<Modal
title="客户端密钥已重新生成"
visible={showSecretModal}
onCancel={() => setShowSecretModal(false)}
onOk={() => setShowSecretModal(false)}
cancelText=""
okText="我已复制保存"
width={600}
>
<div>
<Text>新的客户端密钥如下请立即复制保存关闭此窗口后将无法再次查看</Text>
<div style={{
background: '#f8f9fa',
padding: '16px',
borderRadius: '6px',
marginTop: '16px',
fontFamily: 'monospace',
wordBreak: 'break-all'
}}>
<Text code copyable>{currentSecret}</Text>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,351 @@
/*
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, useRef } from 'react';
import { Banner, Button, Col, Form, Row, Card } from '@douyinfe/semi-ui';
import {
compareObjects,
API,
showError,
showSuccess,
showWarning,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function OAuth2ServerSettings(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
'oauth2.enabled': false,
'oauth2.issuer': '',
'oauth2.access_token_ttl': 10,
'oauth2.refresh_token_ttl': 720,
'oauth2.jwt_signing_algorithm': 'RS256',
'oauth2.jwt_key_id': 'oauth2-key-1',
'oauth2.jwt_private_key_file': '',
'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code'],
'oauth2.require_pkce': true,
'oauth2.auto_create_user': false,
'oauth2.default_user_role': 1,
'oauth2.default_user_group': 'default',
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
function handleFieldChange(fieldName) {
return (value) => {
setInputs((inputs) => ({ ...inputs, [fieldName]: value }));
};
}
function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = '';
if (typeof inputs[item.key] === 'boolean') {
value = String(inputs[item.key]);
} else if (Array.isArray(inputs[item.key])) {
value = JSON.stringify(inputs[item.key]);
} else {
value = inputs[item.key];
}
return API.put('/api/option/', {
key: item.key,
value,
});
});
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined))
return showError(t('部分保存失败,请重试'));
}
showSuccess(t('保存成功'));
if (props && props.refresh) {
props.refresh();
}
})
.catch(() => {
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
});
}
// 测试OAuth2连接
const testOAuth2 = async () => {
try {
const res = await API.get('/api/oauth/server-info');
if (res.data.success) {
showSuccess('OAuth2服务器运行正常');
} else {
showError('OAuth2服务器测试失败: ' + res.data.message);
}
} catch (error) {
showError('OAuth2服务器连接测试失败');
}
};
useEffect(() => {
if (props && props.options) {
const currentInputs = {};
for (let key in props.options) {
if (Object.keys(inputs).includes(key)) {
if (key === 'oauth2.allowed_grant_types') {
try {
currentInputs[key] = JSON.parse(props.options[key] || '["client_credentials","authorization_code"]');
} catch {
currentInputs[key] = ['client_credentials', 'authorization_code'];
}
} else if (typeof inputs[key] === 'boolean') {
currentInputs[key] = props.options[key] === 'true';
} else if (typeof inputs[key] === 'number') {
currentInputs[key] = parseInt(props.options[key]) || inputs[key];
} else {
currentInputs[key] = props.options[key];
}
}
}
setInputs({...inputs, ...currentInputs});
setInputsRow(structuredClone({...inputs, ...currentInputs}));
if (refForm.current) {
refForm.current.setValues({...inputs, ...currentInputs});
}
}
}, [props]);
return (
<div>
<Card>
<Form
initValues={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
>
<Form.Section text={'OAuth2 服务器设置'}>
<Banner
type="info"
description={
<div>
<p> OAuth2服务器提供标准的API认证和授权功能</p>
<p> 支持Client CredentialsAuthorization Code + PKCE等标准流程</p>
<p> 更改配置后需要重启服务才能生效</p>
<p> 生产环境务必配置HTTPS和安全的JWT签名密钥</p>
</div>
}
style={{ marginBottom: 15 }}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='oauth2.enabled'
label={t('启用OAuth2服务器')}
checkedText='开'
uncheckedText='关'
value={inputs['oauth2.enabled']}
onChange={handleFieldChange('oauth2.enabled')}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Input
field='oauth2.issuer'
label={t('签发者标识(Issuer)')}
placeholder="https://your-domain.com"
extraText="OAuth2令牌的签发者通常是您的域名"
value={inputs['oauth2.issuer']}
onChange={handleFieldChange('oauth2.issuer')}
/>
</Col>
</Row>
<Button onClick={onSubmit} loading={loading}>{t('更新服务器设置')}</Button>
</Form.Section>
</Form>
</Card>
<Card style={{ marginTop: 10 }}>
<Form
initValues={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
>
<Form.Section text={'令牌配置'}>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='oauth2.access_token_ttl'
label={t('访问令牌有效期')}
suffix="分钟"
min={1}
max={1440}
value={inputs['oauth2.access_token_ttl']}
onChange={handleFieldChange('oauth2.access_token_ttl')}
extraText="访问令牌的有效时间建议较短10-60分钟"
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='oauth2.refresh_token_ttl'
label={t('刷新令牌有效期')}
suffix="小时"
min={1}
max={8760}
value={inputs['oauth2.refresh_token_ttl']}
onChange={handleFieldChange('oauth2.refresh_token_ttl')}
extraText="刷新令牌的有效时间建议较长12-720小时"
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='oauth2.jwt_key_id'
label={t('JWT密钥ID')}
placeholder="oauth2-key-1"
value={inputs['oauth2.jwt_key_id']}
onChange={handleFieldChange('oauth2.jwt_key_id')}
extraText="用于标识JWT签名密钥支持密钥轮换"
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Select
field='oauth2.jwt_signing_algorithm'
label={t('JWT签名算法')}
value={inputs['oauth2.jwt_signing_algorithm']}
onChange={handleFieldChange('oauth2.jwt_signing_algorithm')}
extraText="JWT令牌的签名算法推荐使用RS256"
>
<Form.Select.Option value="RS256">RS256 (RSA with SHA-256)</Form.Select.Option>
<Form.Select.Option value="HS256">HS256 (HMAC with SHA-256)</Form.Select.Option>
</Form.Select>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='oauth2.jwt_private_key_file'
label={t('JWT私钥文件路径')}
placeholder="/path/to/oauth2-private-key.pem"
value={inputs['oauth2.jwt_private_key_file']}
onChange={handleFieldChange('oauth2.jwt_private_key_file')}
extraText="RSA私钥文件路径留空将使用内存生成的密钥"
/>
</Col>
</Row>
<Button onClick={onSubmit} loading={loading}>{t('更新令牌配置')}</Button>
</Form.Section>
</Form>
</Card>
<Card style={{ marginTop: 10 }}>
<Form
initValues={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
>
<Form.Section text={'授权配置'}>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Select
field='oauth2.allowed_grant_types'
label={t('允许的授权类型')}
multiple
value={inputs['oauth2.allowed_grant_types']}
onChange={handleFieldChange('oauth2.allowed_grant_types')}
extraText="选择允许的OAuth2授权流程"
>
<Form.Select.Option value="client_credentials">Client Credentials客户端凭证</Form.Select.Option>
<Form.Select.Option value="authorization_code">Authorization Code授权码</Form.Select.Option>
<Form.Select.Option value="refresh_token">Refresh Token刷新令牌</Form.Select.Option>
</Form.Select>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Switch
field='oauth2.require_pkce'
label={t('强制PKCE验证')}
checkedText='开'
uncheckedText='关'
value={inputs['oauth2.require_pkce']}
onChange={handleFieldChange('oauth2.require_pkce')}
extraText="为授权码流程强制启用PKCE提高安全性"
/>
</Col>
</Row>
<Button onClick={onSubmit} loading={loading}>{t('更新授权配置')}</Button>
</Form.Section>
</Form>
</Card>
<Card style={{ marginTop: 10 }}>
<Form
initValues={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
>
<Form.Section text={'用户配置'}>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='oauth2.auto_create_user'
label={t('自动创建用户')}
checkedText='开'
uncheckedText='关'
value={inputs['oauth2.auto_create_user']}
onChange={handleFieldChange('oauth2.auto_create_user')}
extraText="首次OAuth2登录时自动创建用户账户"
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Select
field='oauth2.default_user_role'
label={t('默认用户角色')}
value={inputs['oauth2.default_user_role']}
onChange={handleFieldChange('oauth2.default_user_role')}
extraText="自动创建用户时的默认角色"
>
<Form.Select.Option value={1}>普通用户</Form.Select.Option>
<Form.Select.Option value={10}>管理员</Form.Select.Option>
<Form.Select.Option value={100}>超级管理员</Form.Select.Option>
</Form.Select>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='oauth2.default_user_group'
label={t('默认用户分组')}
placeholder="default"
value={inputs['oauth2.default_user_group']}
onChange={handleFieldChange('oauth2.default_user_group')}
extraText="自动创建用户时的默认分组"
/>
</Col>
</Row>
<Button onClick={onSubmit} loading={loading}>{t('更新用户配置')}</Button>
<Button
type="secondary"
onClick={testOAuth2}
style={{ marginLeft: 8 }}
>
{t('测试连接')}
</Button>
</Form.Section>
</Form>
</Card>
</div>
);
}

View File

@@ -32,6 +32,7 @@ import {
MessageSquare,
Palette,
CreditCard,
Shield,
} from 'lucide-react';
import SystemSetting from '../../components/settings/SystemSetting';
@@ -45,6 +46,7 @@ import RatioSetting from '../../components/settings/RatioSetting';
import ChatsSetting from '../../components/settings/ChatsSetting';
import DrawingSetting from '../../components/settings/DrawingSetting';
import PaymentSetting from '../../components/settings/PaymentSetting';
import OAuth2Setting from '../../components/settings/OAuth2Setting';
const Setting = () => {
const { t } = useTranslation();
@@ -134,6 +136,16 @@ const Setting = () => {
content: <ModelSetting />,
itemKey: 'models',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<Shield size={18} />
{t('OAuth2 & SSO')}
</span>
),
content: <OAuth2Setting />,
itemKey: 'oauth2',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>