mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-06-07 22:09:57 +00:00
wip sso
This commit is contained in:
318
web/src/components/modals/oauth2/CreateOAuth2ClientModal.jsx
Normal file
318
web/src/components/modals/oauth2/CreateOAuth2ClientModal.jsx
Normal 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 }}>
|
||||
PKCE(Proof 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;
|
||||
306
web/src/components/modals/oauth2/EditOAuth2ClientModal.jsx
Normal file
306
web/src/components/modals/oauth2/EditOAuth2ClientModal.jsx
Normal 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 }}>
|
||||
PKCE(Proof 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;
|
||||
101
web/src/components/settings/OAuth2Setting.jsx
Normal file
101
web/src/components/settings/OAuth2Setting.jsx
Normal 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;
|
||||
420
web/src/pages/Setting/OAuth2/OAuth2ClientSettings.jsx
Normal file
420
web/src/pages/Setting/OAuth2/OAuth2ClientSettings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
351
web/src/pages/Setting/OAuth2/OAuth2ServerSettings.jsx
Normal file
351
web/src/pages/Setting/OAuth2/OAuth2ServerSettings.jsx
Normal 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 Credentials、Authorization 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>
|
||||
);
|
||||
}
|
||||
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user