mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-06-07 22:09:57 +00:00
✨ feat(oauth): redesign consent page with GitHub-style UI and improved UX
- Redesign OAuth consent page layout with centered card design - Implement GitHub-style authorization flow presentation - Add application popover with detailed information on hover - Replace generic icons with scope-specific icons (email, profile, admin, etc.) - Integrate i18n support for all hardcoded strings - Optimize permission display with encapsulated ScopeItem component - Improve visual hierarchy with Semi UI Divider components - Unify avatar sizes and implement dynamic color generation - Move action buttons and redirect info to card footer - Add separate meta information card for technical details - Remove redundant color styles to rely on Semi UI theming - Enhance user account section with clearer GitHub-style messaging - Replace dot separators with Lucide icons for better visual consistency - Add site logo with fallback mechanism for branding - Implement responsive design with Tailwind CSS utilities This redesign significantly improves the OAuth consent experience by following modern UI patterns and providing clearer information hierarchy for users.
This commit is contained in:
@@ -18,17 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Space, Button } from '@douyinfe/semi-ui';
|
||||
import { Spin } from '@douyinfe/semi-ui';
|
||||
import { API, showError } from '../../helpers';
|
||||
import OAuth2ServerSettings from '../../pages/Setting/OAuth2/OAuth2ServerSettings';
|
||||
import OAuth2ClientSettings from '../../pages/Setting/OAuth2/OAuth2ClientSettings';
|
||||
// import OAuth2Tools from '../../pages/Setting/OAuth2/OAuth2Tools';
|
||||
import OAuth2ToolsModal from '../../components/modals/oauth2/OAuth2ToolsModal';
|
||||
import OAuth2QuickStartModal from '../../components/modals/oauth2/OAuth2QuickStartModal';
|
||||
import JWKSManagerModal from '../../components/modals/oauth2/JWKSManagerModal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import OAuth2ServerSettings from './oauth2/OAuth2ServerSettings';
|
||||
import OAuth2ClientSettings from './oauth2/OAuth2ClientSettings';
|
||||
|
||||
const OAuth2Setting = () => {
|
||||
// 原样保存后端 Option 键值(字符串),避免类型转换造成子组件解析错误
|
||||
const { t } = useTranslation();
|
||||
const [options, setOptions] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -47,7 +44,7 @@ const OAuth2Setting = () => {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('获取OAuth2设置失败');
|
||||
showError(t('获取OAuth2设置失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -61,33 +58,17 @@ const OAuth2Setting = () => {
|
||||
getOptions();
|
||||
}, []);
|
||||
|
||||
const [qsVisible, setQsVisible] = useState(false);
|
||||
const [jwksVisible, setJwksVisible] = useState(false);
|
||||
const [toolsVisible, setToolsVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<Space>
|
||||
<Button type='primary' onClick={()=>setQsVisible(true)}>一键初始化向导</Button>
|
||||
<Button onClick={()=>setJwksVisible(true)}>JWKS 管理</Button>
|
||||
<Button onClick={()=>setToolsVisible(true)}>调试助手</Button>
|
||||
<Button onClick={()=>window.open('/oauth-demo.html','_blank')}>前端 Demo</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<OAuth2QuickStartModal visible={qsVisible} onClose={()=>setQsVisible(false)} onDone={refresh} />
|
||||
<JWKSManagerModal visible={jwksVisible} onClose={()=>setJwksVisible(false)} />
|
||||
<OAuth2ToolsModal visible={toolsVisible} onClose={()=>setToolsVisible(false)} />
|
||||
<OAuth2ServerSettings options={options} refresh={refresh} onOpenJWKS={()=>setJwksVisible(true)} />
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 服务器配置 */}
|
||||
<OAuth2ServerSettings
|
||||
options={options}
|
||||
refresh={refresh}
|
||||
/>
|
||||
|
||||
{/* 客户端管理 */}
|
||||
<OAuth2ClientSettings />
|
||||
</div>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
504
web/src/components/settings/oauth2/OAuth2ClientSettings.jsx
Normal file
504
web/src/components/settings/oauth2/OAuth2ClientSettings.jsx
Normal file
@@ -0,0 +1,504 @@
|
||||
/*
|
||||
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,
|
||||
Banner,
|
||||
Row,
|
||||
Col,
|
||||
Empty,
|
||||
Tooltip
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Edit,
|
||||
Key,
|
||||
Trash2,
|
||||
Eye,
|
||||
User,
|
||||
Grid3X3
|
||||
} from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import CreateOAuth2ClientModal from './modals/CreateOAuth2ClientModal';
|
||||
import EditOAuth2ClientModal from './modals/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(t('加载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(t('删除成功'));
|
||||
loadClients();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// 重新生成密钥
|
||||
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(t('重新生成密钥失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// 快速查看服务器信息
|
||||
const showServerInfo = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/oauth/server-info');
|
||||
Modal.info({
|
||||
title: t('OAuth2 服务器信息'),
|
||||
content: (
|
||||
<div>
|
||||
<Text>{t('授权服务器配置')}:</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(t('获取服务器信息失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// 查看JWKS
|
||||
const showJWKS = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/oauth/jwks');
|
||||
Modal.info({
|
||||
title: t('JWKS 信息'),
|
||||
content: (
|
||||
<div>
|
||||
<Text>{t('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(t('获取JWKS失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: t('客户端信息'),
|
||||
key: 'info',
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
|
||||
<User size={16} style={{ marginRight: 6, color: 'var(--semi-color-text-2)' }} />
|
||||
<Text strong>{record.name}</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Grid3X3 size={16} style={{ marginRight: 6, color: 'var(--semi-color-text-2)' }} />
|
||||
<Text type="tertiary" size="small" code copyable>
|
||||
{record.id}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: t('类型'),
|
||||
dataIndex: 'client_type',
|
||||
key: 'client_type',
|
||||
render: (text) => (
|
||||
<Tag
|
||||
color={text === 'confidential' ? 'blue' : 'green'}
|
||||
style={{ borderRadius: '12px' }}
|
||||
>
|
||||
{text === 'confidential' ? t('机密客户端') : t('公开客户端')}
|
||||
</Tag>
|
||||
),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: t('授权类型'),
|
||||
dataIndex: 'grant_types',
|
||||
key: 'grant_types',
|
||||
render: (grantTypes) => {
|
||||
const types = typeof grantTypes === 'string' ? grantTypes.split(',') : (grantTypes || []);
|
||||
const typeMap = {
|
||||
'client_credentials': t('客户端凭证'),
|
||||
'authorization_code': t('授权码'),
|
||||
'refresh_token': t('刷新令牌')
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
{types.slice(0, 2).map(type => (
|
||||
<Tag key={type} size="small" style={{ margin: '1px', borderRadius: '8px' }}>
|
||||
{typeMap[type] || type}
|
||||
</Tag>
|
||||
))}
|
||||
{types.length > 2 && (
|
||||
<Tooltip content={types.slice(2).map(t => typeMap[t] || t).join(', ')}>
|
||||
<Tag size="small" style={{ margin: '1px', borderRadius: '8px' }}>
|
||||
+{types.length - 2}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status) => (
|
||||
<Tag
|
||||
color={status === 1 ? 'green' : 'red'}
|
||||
style={{ borderRadius: '12px' }}
|
||||
>
|
||||
{status === 1 ? t('启用') : t('禁用')}
|
||||
</Tag>
|
||||
),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'created_time',
|
||||
key: 'created_time',
|
||||
render: (time) => new Date(time * 1000).toLocaleString(),
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip content={t('编辑客户端')}>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<Edit size={14} />}
|
||||
onClick={() => {
|
||||
setEditingClient(record);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
{record.client_type === 'confidential' && (
|
||||
<Popconfirm
|
||||
title={t('确认重新生成客户端密钥?')}
|
||||
content={
|
||||
<div>
|
||||
<div>{t('客户端')}:{record.name}</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--semi-color-warning)' }}>
|
||||
⚠️ {t('操作不可撤销,旧密钥将立即失效。')}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onConfirm={() => handleRegenerateSecret(record)}
|
||||
okText={t('确认')}
|
||||
cancelText={t('取消')}
|
||||
>
|
||||
<Tooltip content={t('重新生成密钥')}>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="secondary"
|
||||
size="small"
|
||||
icon={<Key size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('请再次确认删除该客户端')}
|
||||
content={
|
||||
<div>
|
||||
<div>{t('客户端')}:{record.name}</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--semi-color-danger)' }}>
|
||||
🗑️ {t('删除后无法恢复,相关 API 调用将立即失效。')}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText={t('确定删除')}
|
||||
cancelText={t('取消')}
|
||||
>
|
||||
<Tooltip content={t('删除客户端')}>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
size="small"
|
||||
icon={<Trash2 size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
style={{ marginTop: 10 }}
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<User size={18} className='mr-2' />
|
||||
<Text strong>{t('OAuth2 客户端管理')}</Text>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text type="tertiary">
|
||||
{t('管理OAuth2客户端应用程序,每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用,公开客户端用于移动应用或单页应用。')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={24} md={10} lg={8}>
|
||||
<Input
|
||||
prefix={<Search size={16} />}
|
||||
placeholder={t('搜索客户端名称、ID或描述')}
|
||||
value={searchKeyword}
|
||||
onChange={handleSearch}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={14} lg={16}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={loadClients}
|
||||
size="default"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('刷新')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Eye size={16} />}
|
||||
onClick={showServerInfo}
|
||||
size="default"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('服务器信息')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Key size={16} />}
|
||||
onClick={showJWKS}
|
||||
size="default"
|
||||
>
|
||||
<span className="hidden md:inline">{t('查看JWKS')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
size="default"
|
||||
>
|
||||
{t('创建客户端')}
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 客户端表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredClients}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => t('第 {{start}}-{{end}} 条,共 {{total}} 条', { start: range[0], end: range[1], total }),
|
||||
pageSize: 10,
|
||||
size: 'small'
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
empty={
|
||||
<Empty
|
||||
image={<User size={48} />}
|
||||
title={t('暂无OAuth2客户端')}
|
||||
description={t('还没有创建任何客户端,点击下方按钮创建第一个客户端')}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
{t('创建第一个客户端')}
|
||||
</Button>
|
||||
</Empty>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 创建客户端模态框 */}
|
||||
<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={t('客户端密钥已重新生成')}
|
||||
visible={showSecretModal}
|
||||
onCancel={() => setShowSecretModal(false)}
|
||||
onOk={() => setShowSecretModal(false)}
|
||||
cancelText=""
|
||||
okText={t('我已复制保存')}
|
||||
width={600}
|
||||
>
|
||||
<div>
|
||||
<Banner
|
||||
type="warning"
|
||||
description={t('新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<div style={{
|
||||
background: '#f8f9fa',
|
||||
padding: '16px',
|
||||
borderRadius: '6px',
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
border: '1px solid var(--semi-color-border)'
|
||||
}}>
|
||||
<Text code copyable style={{ fontSize: '14px' }}>
|
||||
{currentSecret}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
536
web/src/components/settings/oauth2/OAuth2ServerSettings.jsx
Normal file
536
web/src/components/settings/oauth2/OAuth2ServerSettings.jsx
Normal file
@@ -0,0 +1,536 @@
|
||||
/*
|
||||
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,
|
||||
Typography,
|
||||
Space,
|
||||
Tag
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Server,
|
||||
Key,
|
||||
Shield,
|
||||
Settings,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
PlayCircle,
|
||||
Wrench,
|
||||
BookOpen
|
||||
} from 'lucide-react';
|
||||
import OAuth2ToolsModal from './modals/OAuth2ToolsModal';
|
||||
import OAuth2QuickStartModal from './modals/OAuth2QuickStartModal';
|
||||
import JWKSManagerModal from './modals/JWKSManagerModal';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
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.allowed_grant_types': ['client_credentials', 'authorization_code', 'refresh_token'],
|
||||
'oauth2.require_pkce': true,
|
||||
'oauth2.max_jwks_keys': 3,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const [keysReady, setKeysReady] = useState(true);
|
||||
const [keysLoading, setKeysLoading] = useState(false);
|
||||
const [serverInfo, setServerInfo] = useState(null);
|
||||
|
||||
// 模态框状态
|
||||
const [qsVisible, setQsVisible] = useState(false);
|
||||
const [jwksVisible, setJwksVisible] = useState(false);
|
||||
const [toolsVisible, setToolsVisible] = useState(false);
|
||||
|
||||
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.status === 200 && (res.data.issuer || res.data.authorization_endpoint)) {
|
||||
showSuccess('OAuth2服务器运行正常');
|
||||
setServerInfo(res.data);
|
||||
} else {
|
||||
showError('OAuth2服务器测试失败');
|
||||
}
|
||||
} 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","refresh_token"]');
|
||||
} catch {
|
||||
currentInputs[key] = ['client_credentials', 'authorization_code', 'refresh_token'];
|
||||
}
|
||||
} 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]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
setKeysLoading(true);
|
||||
const res = await API.get('/api/oauth/keys', { skipErrorHandler: true });
|
||||
const list = res?.data?.data || [];
|
||||
setKeysReady(list.length > 0);
|
||||
} catch {
|
||||
setKeysReady(false);
|
||||
} finally {
|
||||
setKeysLoading(false);
|
||||
}
|
||||
};
|
||||
if (inputs['oauth2.enabled']) {
|
||||
loadKeys();
|
||||
testOAuth2();
|
||||
}
|
||||
}, [inputs['oauth2.enabled']]);
|
||||
|
||||
const isEnabled = inputs['oauth2.enabled'];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* OAuth2 & SSO 管理 */}
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
style={{ marginTop: 10 }}
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<Server size={18} className='mr-2' />
|
||||
<Text strong>{t('OAuth2 & SSO 管理')}</Text>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text type="tertiary">
|
||||
{t('OAuth2 是一个开放标准的授权框架,允许用户授权第三方应用访问他们的资源,而无需分享他们的凭据。支持标准的 API 认证与授权流程。')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{!isEnabled && (
|
||||
<Banner
|
||||
type="info"
|
||||
icon={<Settings size={16} />}
|
||||
description={t('OAuth2 功能尚未启用,建议使用一键初始化向导完成基础配置。')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 快捷操作按钮 */}
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||
<Col xs={12} sm={6} md={6} lg={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircle size={16} />}
|
||||
onClick={() => setQsVisible(true)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('一键初始化')}</span>
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={6} lg={6}>
|
||||
<Button
|
||||
icon={<Key size={16} />}
|
||||
onClick={() => setJwksVisible(true)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('密钥管理')}</span>
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={6} lg={6}>
|
||||
<Button
|
||||
icon={<Wrench size={16} />}
|
||||
onClick={() => setToolsVisible(true)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('调试助手')}</span>
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={6} lg={6}>
|
||||
<Button
|
||||
icon={<BookOpen size={16} />}
|
||||
onClick={() => window.open('/oauth-demo.html', '_blank')}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('前端演示')}</span>
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
{!keysReady && isEnabled && (
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={<AlertTriangle size={16} />}
|
||||
description={
|
||||
<div>
|
||||
<div>⚠️ 尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。</div>
|
||||
<div>签名密钥用于 JWT 令牌的安全签发。</div>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
size='small'
|
||||
type="primary"
|
||||
onClick={() => setJwksVisible(true)}
|
||||
loading={keysLoading}
|
||||
>
|
||||
打开密钥管理
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row gutter={[16, 24]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Switch
|
||||
field='oauth2.enabled'
|
||||
label={
|
||||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Shield size={16} style={{ marginRight: 4 }} />
|
||||
{t('启用 OAuth2 & SSO')}
|
||||
</span>
|
||||
}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
value={inputs['oauth2.enabled']}
|
||||
onChange={handleFieldChange('oauth2.enabled')}
|
||||
extraText={t("开启后将允许以 OAuth2/OIDC 标准进行授权与登录")}
|
||||
size="large"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Input
|
||||
field='oauth2.issuer'
|
||||
label={t('发行人 (Issuer)')}
|
||||
placeholder={window.location.origin}
|
||||
value={inputs['oauth2.issuer']}
|
||||
onChange={handleFieldChange('oauth2.issuer')}
|
||||
extraText={t("为空则按请求自动推断(含 X-Forwarded-Proto)")}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 服务器状态 */}
|
||||
{isEnabled && serverInfo && (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: '12px 16px',
|
||||
backgroundColor: 'var(--semi-color-success-light-default)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--semi-color-success-light-active)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
|
||||
<CheckCircle size={16} style={{ marginRight: 6, color: 'var(--semi-color-success)' }} />
|
||||
<Text strong style={{ color: 'var(--semi-color-success)' }}>{t('服务器运行正常')}</Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Tag color="green">{t('发行人')}: {serverInfo.issuer}</Tag>
|
||||
{serverInfo.authorization_endpoint && <Tag>{t('授权端点')}: {t('已配置')}</Tag>}
|
||||
{serverInfo.token_endpoint && <Tag>{t('令牌端点')}: {t('已配置')}</Tag>}
|
||||
{serverInfo.jwks_uri && <Tag>JWKS: {t('已配置')}</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" onClick={onSubmit} loading={loading}>
|
||||
{t('保存基础配置')}
|
||||
</Button>
|
||||
{isEnabled && (
|
||||
<Button
|
||||
type="secondary"
|
||||
onClick={testOAuth2}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* 高级配置 */}
|
||||
{isEnabled && (
|
||||
<>
|
||||
{/* 令牌配置 */}
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
style={{ marginTop: 10 }}
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<Key size={18} className='mr-2' />
|
||||
<Text strong>{t('令牌配置')}</Text>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<Text type='tertiary' size='small'>
|
||||
<div className='space-y-1'>
|
||||
<div>• {t('OAuth2 服务器提供标准的 API 认证与授权')}</div>
|
||||
<div>• {t('支持 Client Credentials、Authorization Code + PKCE 等标准流程')}</div>
|
||||
<div>• {t('配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作')}</div>
|
||||
<div>• {t('生产环境务必启用 HTTPS,并妥善管理 JWT 签名密钥')}</div>
|
||||
</div>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
|
||||
<Form initValues={inputs}>
|
||||
<Row gutter={[16, 24]}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Form.InputNumber
|
||||
field='oauth2.access_token_ttl'
|
||||
label={t('访问令牌有效期')}
|
||||
suffix={t("分钟")}
|
||||
min={1}
|
||||
max={1440}
|
||||
value={inputs['oauth2.access_token_ttl']}
|
||||
onChange={handleFieldChange('oauth2.access_token_ttl')}
|
||||
extraText={t("访问令牌的有效时间,建议较短(10-60分钟)")}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Form.InputNumber
|
||||
field='oauth2.refresh_token_ttl'
|
||||
label={t('刷新令牌有效期')}
|
||||
suffix={t("小时")}
|
||||
min={1}
|
||||
max={8760}
|
||||
value={inputs['oauth2.refresh_token_ttl']}
|
||||
onChange={handleFieldChange('oauth2.refresh_token_ttl')}
|
||||
extraText={t("刷新令牌的有效时间,建议较长(12-720小时)")}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Form.InputNumber
|
||||
field='oauth2.max_jwks_keys'
|
||||
label={t('JWKS历史保留上限')}
|
||||
min={1}
|
||||
max={10}
|
||||
value={inputs['oauth2.max_jwks_keys']}
|
||||
onChange={handleFieldChange('oauth2.max_jwks_keys')}
|
||||
extraText={t("轮换后最多保留的历史签名密钥数量")}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 24]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Select
|
||||
field='oauth2.jwt_signing_algorithm'
|
||||
label={t('JWT签名算法')}
|
||||
value={inputs['oauth2.jwt_signing_algorithm']}
|
||||
onChange={handleFieldChange('oauth2.jwt_signing_algorithm')}
|
||||
extraText={t("JWT令牌的签名算法,推荐使用RS256")}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<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} lg={12}>
|
||||
<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={t("用于标识JWT签名密钥,支持密钥轮换")}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" onClick={onSubmit} loading={loading}>
|
||||
{t('更新令牌配置')}
|
||||
</Button>
|
||||
<Button
|
||||
type='secondary'
|
||||
onClick={() => setJwksVisible(true)}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
密钥管理
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* 授权配置 */}
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
style={{ marginTop: 10 }}
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<Settings size={18} className='mr-2' />
|
||||
<Text strong>{t('授权配置')}</Text>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
<Form initValues={inputs}>
|
||||
<Row gutter={[16, 24]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Select
|
||||
field='oauth2.allowed_grant_types'
|
||||
label={t('允许的授权类型')}
|
||||
multiple
|
||||
value={inputs['oauth2.allowed_grant_types']}
|
||||
onChange={handleFieldChange('oauth2.allowed_grant_types')}
|
||||
extraText={t("选择允许的OAuth2授权流程")}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Select.Option value="client_credentials">{t('Client Credentials(客户端凭证)')}</Form.Select.Option>
|
||||
<Form.Select.Option value="authorization_code">{t('Authorization Code(授权码)')}</Form.Select.Option>
|
||||
<Form.Select.Option value="refresh_token">{t('Refresh Token(刷新令牌)')}</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Switch
|
||||
field='oauth2.require_pkce'
|
||||
label={t('强制PKCE验证')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
value={inputs['oauth2.require_pkce']}
|
||||
onChange={handleFieldChange('oauth2.require_pkce')}
|
||||
extraText={t("为授权码流程强制启用PKCE,提高安全性")}
|
||||
size="large"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" onClick={onSubmit} loading={loading}>
|
||||
{t('更新授权配置')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 模态框 */}
|
||||
<OAuth2QuickStartModal
|
||||
visible={qsVisible}
|
||||
onClose={() => setQsVisible(false)}
|
||||
onDone={() => { props?.refresh && props.refresh(); }}
|
||||
/>
|
||||
<JWKSManagerModal
|
||||
visible={jwksVisible}
|
||||
onClose={() => setJwksVisible(false)}
|
||||
/>
|
||||
<OAuth2ToolsModal
|
||||
visible={toolsVisible}
|
||||
onClose={() => setToolsVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,27 +17,29 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
TextArea,
|
||||
Switch,
|
||||
Space,
|
||||
Typography,
|
||||
Divider,
|
||||
Tag,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess, showInfo } from '../../../helpers';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [redirectUris, setRedirectUris] = useState([]);
|
||||
@@ -137,27 +139,27 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
|
||||
// 业务校验
|
||||
if (!grantTypes.length) {
|
||||
showError('请至少选择一种授权类型');
|
||||
showError(t('请至少选择一种授权类型'));
|
||||
return;
|
||||
}
|
||||
// 校验是否包含不被允许的授权类型
|
||||
const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g));
|
||||
if (invalids.length) {
|
||||
showError(`不被允许的授权类型: ${invalids.join(', ')}`);
|
||||
showError(t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') }));
|
||||
return;
|
||||
}
|
||||
if (clientType === 'public' && grantTypes.includes('client_credentials')) {
|
||||
showError('公开客户端不允许使用client_credentials授权类型');
|
||||
showError(t('公开客户端不允许使用client_credentials授权类型'));
|
||||
return;
|
||||
}
|
||||
if (grantTypes.includes('authorization_code')) {
|
||||
if (!validRedirectUris.length) {
|
||||
showError('选择授权码授权类型时,必须填写至少一个重定向URI');
|
||||
showError(t('选择授权码授权类型时,必须填写至少一个重定向URI'));
|
||||
return;
|
||||
}
|
||||
const allValid = validRedirectUris.every(isValidRedirectUri);
|
||||
if (!allValid) {
|
||||
showError('重定向URI格式不合法:仅支持https,或本地开发使用http');
|
||||
showError(t('重定向URI格式不合法:仅支持https,或本地开发使用http'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -173,17 +175,17 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
const { success, message, client_id, client_secret } = res.data;
|
||||
|
||||
if (success) {
|
||||
showSuccess('OAuth2客户端创建成功');
|
||||
showSuccess(t('OAuth2客户端创建成功'));
|
||||
|
||||
// 显示客户端信息
|
||||
Modal.info({
|
||||
title: '客户端创建成功',
|
||||
title: t('客户端创建成功'),
|
||||
content: (
|
||||
<div>
|
||||
<Paragraph>请妥善保存以下信息:</Paragraph>
|
||||
<Paragraph>{t('请妥善保存以下信息:')}</Paragraph>
|
||||
<div style={{ background: '#f8f9fa', padding: '16px', borderRadius: '6px' }}>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Text strong>客户端ID:</Text>
|
||||
<Text strong>{t('客户端ID')}:</Text>
|
||||
<br />
|
||||
<Text code copyable style={{ fontFamily: 'monospace' }}>
|
||||
{client_id}
|
||||
@@ -191,7 +193,7 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
</div>
|
||||
{client_secret && (
|
||||
<div>
|
||||
<Text strong>客户端密钥(仅此一次显示):</Text>
|
||||
<Text strong>{t('客户端密钥(仅此一次显示)')}:</Text>
|
||||
<br />
|
||||
<Text code copyable style={{ fontFamily: 'monospace' }}>
|
||||
{client_secret}
|
||||
@@ -201,8 +203,8 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
</div>
|
||||
<Paragraph type="warning" style={{ marginTop: '12px' }}>
|
||||
{client_secret
|
||||
? '客户端密钥仅显示一次,请立即复制保存。'
|
||||
: '公开客户端无需密钥。'
|
||||
? t('客户端密钥仅显示一次,请立即复制保存。')
|
||||
: t('公开客户端无需密钥。')
|
||||
}
|
||||
</Paragraph>
|
||||
</div>
|
||||
@@ -217,7 +219,7 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('创建OAuth2客户端失败');
|
||||
showError(t('创建OAuth2客户端失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -271,15 +273,21 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="创建OAuth2客户端"
|
||||
title={t('创建OAuth2客户端')}
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
onOk={() => formApi?.submitForm()}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
okText={t('创建')}
|
||||
cancelText={t('取消')}
|
||||
confirmLoading={loading}
|
||||
width={600}
|
||||
style={{ top: 50 }}
|
||||
width="90vw"
|
||||
style={{
|
||||
top: 20,
|
||||
maxWidth: '800px',
|
||||
'@media (min-width: 768px)': {
|
||||
width: '600px'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
@@ -293,147 +301,175 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
labelPosition="top"
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<Form.Input
|
||||
field="name"
|
||||
label="客户端名称"
|
||||
placeholder="输入客户端名称"
|
||||
rules={[{ required: true, message: '请输入客户端名称' }]}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field="description"
|
||||
label="描述"
|
||||
placeholder="输入客户端描述"
|
||||
rows={3}
|
||||
/>
|
||||
<Row gutter={[16, 24]}>
|
||||
<Col xs={24}>
|
||||
<Form.Input
|
||||
field="name"
|
||||
label={t('客户端名称')}
|
||||
placeholder={t('输入客户端名称')}
|
||||
rules={[{ required: true, message: t('请输入客户端名称') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24}>
|
||||
<Form.TextArea
|
||||
field="description"
|
||||
label={t('描述')}
|
||||
placeholder={t('输入客户端描述')}
|
||||
rows={3}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 客户端类型 */}
|
||||
<div>
|
||||
<Text strong>客户端类型</Text>
|
||||
<Text strong>{t('客户端类型')}</Text>
|
||||
<Paragraph type="tertiary" size="small" style={{ marginTop: 4, marginBottom: 8 }}>
|
||||
选择适合您应用程序的客户端类型。
|
||||
{t('选择适合您应用程序的客户端类型。')}
|
||||
</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>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} md={12}>
|
||||
<div
|
||||
onClick={() => setClientType('confidential')}
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: `2px solid ${clientType === 'confidential' ? '#3370ff' : '#e4e6e9'}`,
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
background: clientType === 'confidential' ? '#f0f5ff' : '#fff',
|
||||
transition: 'all 0.2s ease',
|
||||
minHeight: '80px'
|
||||
}}
|
||||
>
|
||||
<Text strong>{t('机密客户端(Confidential)')}</Text>
|
||||
<Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
|
||||
{t('用于服务器端应用,可以安全地存储客户端密钥')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div
|
||||
onClick={() => setClientType('public')}
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: `2px solid ${clientType === 'public' ? '#3370ff' : '#e4e6e9'}`,
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
background: clientType === 'public' ? '#f0f5ff' : '#fff',
|
||||
transition: 'all 0.2s ease',
|
||||
minHeight: '80px'
|
||||
}}
|
||||
>
|
||||
<Text strong>{t('公开客户端(Public)')}</Text>
|
||||
<Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
|
||||
{t('用于移动应用或单页应用,无法安全存储密钥')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* 授权类型 */}
|
||||
<Form.Select
|
||||
field="grant_types"
|
||||
label="允许的授权类型"
|
||||
multiple
|
||||
value={grantTypes}
|
||||
onChange={handleGrantTypesChange}
|
||||
rules={[{ required: true, message: '请选择至少一种授权类型' }]}
|
||||
>
|
||||
<Option value="client_credentials" disabled={isGrantTypeDisabled('client_credentials')}>
|
||||
Client Credentials(客户端凭证)
|
||||
</Option>
|
||||
<Option value="authorization_code" disabled={isGrantTypeDisabled('authorization_code')}>
|
||||
Authorization Code(授权码)
|
||||
</Option>
|
||||
<Option value="refresh_token" disabled={isGrantTypeDisabled('refresh_token')}>
|
||||
Refresh Token(刷新令牌)
|
||||
</Option>
|
||||
</Form.Select>
|
||||
<Row gutter={[16, 24]}>
|
||||
{/* 授权类型 */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Select
|
||||
field="grant_types"
|
||||
label={t('允许的授权类型')}
|
||||
multiple
|
||||
value={grantTypes}
|
||||
onChange={handleGrantTypesChange}
|
||||
rules={[{ required: true, message: t('请选择至少一种授权类型') }]}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Option value="client_credentials" disabled={isGrantTypeDisabled('client_credentials')}>
|
||||
{t('Client Credentials(客户端凭证)')}
|
||||
</Option>
|
||||
<Option value="authorization_code" disabled={isGrantTypeDisabled('authorization_code')}>
|
||||
{t('Authorization Code(授权码)')}
|
||||
</Option>
|
||||
<Option value="refresh_token" disabled={isGrantTypeDisabled('refresh_token')}>
|
||||
{t('Refresh Token(刷新令牌)')}
|
||||
</Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
{/* Scope */}
|
||||
<Form.Select
|
||||
field="scopes"
|
||||
label="允许的权限范围(Scope)"
|
||||
multiple
|
||||
rules={[{ required: true, message: '请选择至少一个权限范围' }]}
|
||||
>
|
||||
<Option value="openid">openid(OIDC 基础身份)</Option>
|
||||
<Option value="profile">profile(用户名/昵称等)</Option>
|
||||
<Option value="email">email(邮箱信息)</Option>
|
||||
<Option value="api:read">api:read(读取API)</Option>
|
||||
<Option value="api:write">api:write(写入API)</Option>
|
||||
<Option value="admin">admin(管理员权限)</Option>
|
||||
</Form.Select>
|
||||
{/* Scope */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Select
|
||||
field="scopes"
|
||||
label={t('允许的权限范围(Scope)')}
|
||||
multiple
|
||||
rules={[{ required: true, message: t('请选择至少一个权限范围') }]}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Option value="openid">openid(OIDC 基础身份)</Option>
|
||||
<Option value="profile">profile(用户名/昵称等)</Option>
|
||||
<Option value="email">email(邮箱信息)</Option>
|
||||
<Option value="api:read">api:read(读取API)</Option>
|
||||
<Option value="api:write">api:write(写入API)</Option>
|
||||
<Option value="admin">admin(管理员权限)</Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
{/* 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>
|
||||
{/* PKCE设置 */}
|
||||
<Col xs={24}>
|
||||
<Form.Switch
|
||||
field="require_pkce"
|
||||
label={t('强制PKCE验证')}
|
||||
/>
|
||||
<Paragraph type="tertiary" size="small" style={{ marginTop: 4, marginBottom: 0 }}>
|
||||
{t('PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。')}
|
||||
</Paragraph>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 重定向URI */}
|
||||
{(grantTypes.includes('authorization_code') || redirectUris.length > 0) && (
|
||||
<>
|
||||
<Divider>重定向URI配置</Divider>
|
||||
<Divider>{t('重定向URI配置')}</Divider>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong>重定向URI</Text>
|
||||
<Text strong>{t('重定向URI')}</Text>
|
||||
<Paragraph type="tertiary" size="small">
|
||||
用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。
|
||||
{t('用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。')}
|
||||
</Paragraph>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div 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)}
|
||||
<Row gutter={[8, 8]} key={index} style={{ marginBottom: 8 }}>
|
||||
<Col xs={redirectUris.length > 1 ? 20 : 24}>
|
||||
<Input
|
||||
placeholder="https://your-app.com/callback"
|
||||
value={uri}
|
||||
onChange={(value) => updateRedirectUri(index, value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
{redirectUris.length > 1 && (
|
||||
<Col xs={4} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
size="small"
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => removeRedirectUri(index)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Space>
|
||||
</Row>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<IconPlus />}
|
||||
icon={<Plus size={14} />}
|
||||
onClick={addRedirectUri}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
添加重定向URI
|
||||
{t('添加重定向URI')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
Divider,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
@@ -388,7 +388,7 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => removeRedirectUri(index)}
|
||||
/>
|
||||
)}
|
||||
@@ -400,7 +400,7 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<IconPlus />}
|
||||
icon={<Plus size={14} />}
|
||||
onClick={addRedirectUri}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, Table, Button, Space, Tag, Typography, Popconfirm, Toast, Form, TextArea, Divider, Input } from '@douyinfe/semi-ui';
|
||||
import { IconRefresh, IconDelete, IconPlay } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { RefreshCw, Trash2, PlayCircle } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function JWKSManagerModal({ visible, onClose }) {
|
||||
<Space>
|
||||
{!r.current && (
|
||||
<Popconfirm title={`确定删除密钥 ${r.kid} ?`} content='删除后使用该 kid 签发的旧令牌仍可被验证(外部 JWKS 缓存可能仍保留)' okText='删除' onConfirm={() => del(r.kid)}>
|
||||
<Button icon={<IconDelete />} size='small' theme='borderless'>删除</Button>
|
||||
<Button icon={<Trash2 size={14} />} size='small' theme='borderless'>删除</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
@@ -110,8 +110,8 @@ export default function JWKSManagerModal({ visible, onClose }) {
|
||||
style={{ top: 48 }}
|
||||
>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Button icon={<IconRefresh />} onClick={load} loading={loading}>刷新</Button>
|
||||
<Button icon={<IconPlay />} type='primary' onClick={rotate} loading={loading}>轮换密钥</Button>
|
||||
<Button icon={<RefreshCw size={16} />} onClick={load} loading={loading}>刷新</Button>
|
||||
<Button icon={<PlayCircle size={16} />} type='primary' onClick={rotate} loading={loading}>轮换密钥</Button>
|
||||
<Button onClick={()=>setShowImport(!showImport)}>导入 PEM 私钥</Button>
|
||||
<Button onClick={()=>setShowGenerate(!showGenerate)}>生成 PEM 文件</Button>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Steps, Form, Input, Select, Switch, Typography, Space, Button, Tag, Toast } from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, Form, Input, Button, Space, Select, Typography, Divider, Toast, TextArea } from '@douyinfe/semi-ui';
|
||||
import { API } from '../../../helpers';
|
||||
import { API } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -1,17 +1,63 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, Button, Typography, Tag, Space, Divider, Spin, Banner, Descriptions, Avatar, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconShield, IconTickCircle, IconClose } from '@douyinfe/semi-icons';
|
||||
import { Card, Button, Typography, Spin, Banner, Avatar, Divider, Popover } from '@douyinfe/semi-ui';
|
||||
import { Link, Dot, Key, User, Mail, Eye, Pencil, Shield } from 'lucide-react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { API, showError } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, getLogo } from '../../helpers';
|
||||
import { stringToColor } from '../../helpers/render';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
function useQuery() {
|
||||
const { search } = useLocation();
|
||||
return useMemo(() => new URLSearchParams(search), [search]);
|
||||
}
|
||||
|
||||
// 获取scope对应的图标
|
||||
function getScopeIcon(scopeName) {
|
||||
switch (scopeName) {
|
||||
case 'openid':
|
||||
return Key;
|
||||
case 'profile':
|
||||
return User;
|
||||
case 'email':
|
||||
return Mail;
|
||||
case 'api:read':
|
||||
return Eye;
|
||||
case 'api:write':
|
||||
return Pencil;
|
||||
case 'admin':
|
||||
return Shield;
|
||||
default:
|
||||
return Dot;
|
||||
}
|
||||
}
|
||||
|
||||
// 权限项组件
|
||||
function ScopeItem({ name, description }) {
|
||||
const Icon = getScopeIcon(name);
|
||||
|
||||
return (
|
||||
<div className='flex items-start gap-3 py-2'>
|
||||
<div className='w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5'>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<Text strong className='block'>
|
||||
{name}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text type='tertiary' size='small' className='block mt-1'>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OAuthConsent() {
|
||||
const { t } = useTranslation();
|
||||
const query = useQuery();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [info, setInfo] = useState(null);
|
||||
@@ -57,143 +103,215 @@ export default function OAuthConsent() {
|
||||
})();
|
||||
}, [params]);
|
||||
|
||||
const onApprove = () => {
|
||||
const handleAction = (action) => {
|
||||
const u = new URL(window.location.origin + '/api/oauth/authorize');
|
||||
Object.entries(params).forEach(([k, v]) => u.searchParams.set(k, v));
|
||||
u.searchParams.set('approve', '1');
|
||||
u.searchParams.set(action, '1');
|
||||
window.location.href = u.toString();
|
||||
};
|
||||
const onDeny = () => {
|
||||
const u = new URL(window.location.origin + '/api/oauth/authorize');
|
||||
Object.entries(params).forEach(([k, v]) => u.searchParams.set(k, v));
|
||||
u.searchParams.set('deny', '1');
|
||||
window.location.href = u.toString();
|
||||
};
|
||||
|
||||
const renderScope = () => {
|
||||
if (!info?.scope_info?.length) return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{info?.scope_list?.map((s) => (
|
||||
<Tag key={s} style={{ marginRight: 6, marginBottom: 6 }}>{s}</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{info.scope_info.map((s) => (
|
||||
<Tag key={s.Name} style={{ marginRight: 6, marginBottom: 6 }}>
|
||||
<Tooltip content={s.Description || s.Name}>{s.Name}</Tooltip>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const displayClient = () => (
|
||||
<div>
|
||||
<Space align='center' style={{ marginBottom: 6 }}>
|
||||
<Avatar size='small' style={{ backgroundColor: 'var(--semi-color-tertiary)' }}>
|
||||
{String(info?.client?.name || info?.client?.id || 'A').slice(0, 1).toUpperCase()}
|
||||
</Avatar>
|
||||
<Title heading={5} style={{ margin: 0 }}>{info?.client?.name || info?.client?.id}</Title>
|
||||
{info?.verified && <Tag type='solid' color='green'>已验证</Tag>}
|
||||
{info?.client?.type === 'public' && <Tag>公开客户端</Tag>}
|
||||
{info?.client?.type === 'confidential' && <Tag color='blue'>机密客户端</Tag>}
|
||||
</Space>
|
||||
{info?.client?.desc && (
|
||||
<Paragraph type='tertiary' style={{ marginTop: 0 }}>{info.client.desc}</Paragraph>
|
||||
)}
|
||||
<Descriptions size='small' style={{ marginTop: 8 }} data={[{
|
||||
key: '回调域名', value: info?.redirect_host || '-',
|
||||
}, {
|
||||
key: '申请方域', value: info?.client?.domain || '-',
|
||||
}, {
|
||||
key: '需要PKCE', value: info?.require_pkce ? '是' : '否',
|
||||
}]} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const displayUser = () => (
|
||||
<Space style={{ marginTop: 8 }}>
|
||||
<Avatar size='small'>{String(info?.user?.name || 'U').slice(0,1).toUpperCase()}</Avatar>
|
||||
<Text>{info?.user?.name || '当前用户'}</Text>
|
||||
{info?.user?.email && <Text type='tertiary'>({info.user.email})</Text>}
|
||||
<Button size='small' theme='borderless' onClick={() => {
|
||||
const u = new URL(window.location.origin + '/login');
|
||||
u.searchParams.set('next', '/oauth/consent' + window.location.search);
|
||||
window.location.href = u.toString();
|
||||
}}>切换账户</Button>
|
||||
</Space>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 840, margin: '24px auto 48px', padding: '0 16px' }}>
|
||||
<Card style={{ borderRadius: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<IconShield size='extra-large' />
|
||||
<div>
|
||||
<Title heading={4} style={{ margin: 0 }}>应用请求访问你的账户</Title>
|
||||
<Paragraph type='tertiary' style={{ margin: 0 }}>请确认是否授权下列权限给第三方应用。</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='min-h-screen flex items-center justify-center px-4'>
|
||||
<div className='w-full max-w-lg'>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
<Card className='text-center py-8'>
|
||||
<Spin size='large' />
|
||||
<Text type='tertiary' className='block mt-4'>{t('加载授权信息中...')}</Text>
|
||||
</Card>
|
||||
) : error ? (
|
||||
<Banner type='warning' description={error === 'login_required' ? '请先登录后再继续授权。' : '暂时无法加载授权信息'} />
|
||||
<Card>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={error === 'login_required' ? t('请先登录后再继续授权。') : t('暂时无法加载授权信息')}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
info && (
|
||||
<div>
|
||||
<Divider margin='12px' />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.3fr 0.7fr', gap: 16 }}>
|
||||
<div>
|
||||
{displayClient()}
|
||||
{displayUser()}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type='tertiary'>请求的权限范围</Text>
|
||||
{renderScope()}
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type='tertiary'>回调地址</Text>
|
||||
<Paragraph copyable style={{ marginTop: 4 }}>{info?.redirect_uri}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ background: 'var(--semi-color-fill-0)', border: '1px solid var(--semi-color-border)', borderRadius: 8, padding: 12 }}>
|
||||
<Text type='tertiary'>安全提示</Text>
|
||||
<ul style={{ margin: '8px 0 0 16px', padding: 0 }}>
|
||||
<li>仅在信任的网络环境中授权。</li>
|
||||
<li>确认回调域名与申请方一致{info?.verified ? '(已验证)' : '(未验证)'}。</li>
|
||||
<li>你可以随时在账户设置中撤销授权。</li>
|
||||
</ul>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Descriptions size='small' data={[{
|
||||
key: 'Issuer', value: window.location.origin,
|
||||
}, {
|
||||
key: 'Client ID', value: info?.client?.id || '-',
|
||||
}, {
|
||||
key: '需要PKCE', value: info?.require_pkce ? '是' : '否',
|
||||
}]} />
|
||||
<>
|
||||
<Card
|
||||
className='!rounded-2xl border-0'
|
||||
footer={
|
||||
<div className='space-y-3'>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
theme='outline'
|
||||
onClick={() => handleAction('deny')}
|
||||
className='w-full'
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={() => handleAction('approve')}
|
||||
className='w-full'
|
||||
>
|
||||
{t('授权')} {info?.user?.name || t('用户')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<Text type='tertiary' size='small' className='block'>
|
||||
{t('授权后将重定向到')}
|
||||
</Text>
|
||||
<Text type='tertiary' size='small' className='block'>
|
||||
{info?.redirect_uri?.length > 60 ? info.redirect_uri.slice(0, 60) + '...' : info?.redirect_uri}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* 头部:应用 → 链接 → 站点Logo */}
|
||||
<div className='text-center py-8'>
|
||||
<div className='flex items-center justify-center gap-6 mb-6'>
|
||||
{/* 应用图标 */}
|
||||
<Popover
|
||||
content={
|
||||
<div className='max-w-xs p-2'>
|
||||
<Text strong className='block text-sm mb-1'>
|
||||
{info?.client?.name || info?.client?.id}
|
||||
</Text>
|
||||
{info?.client?.desc && (
|
||||
<Text type='tertiary' size='small' className='block'>
|
||||
{info.client.desc}
|
||||
</Text>
|
||||
)}
|
||||
{info?.client?.domain && (
|
||||
<Text type='tertiary' size='small' className='block mt-1'>
|
||||
{t('域名')}: {info.client.domain}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
trigger='hover'
|
||||
position='top'
|
||||
>
|
||||
<Avatar
|
||||
size={36}
|
||||
style={{
|
||||
backgroundColor: stringToColor(info?.client?.name || info?.client?.id || 'A'),
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{String(info?.client?.name || info?.client?.id || 'A').slice(0, 1).toUpperCase()}
|
||||
</Avatar>
|
||||
</Popover>
|
||||
{/* 链接图标 */}
|
||||
<div className='w-10 h-10 rounded-full flex items-center justify-center'>
|
||||
<Link size={16} />
|
||||
</div>
|
||||
{/* 站点Logo */}
|
||||
<div className='w-12 h-12 rounded-full overflow-hidden flex items-center justify-center'>
|
||||
<img
|
||||
src={getLogo()}
|
||||
alt='Site Logo'
|
||||
className='w-full h-full object-cover'
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='w-full h-full rounded-full flex items-center justify-center'
|
||||
style={{
|
||||
backgroundColor: stringToColor(window.location.hostname || 'S'),
|
||||
display: 'none'
|
||||
}}
|
||||
>
|
||||
<Text className='font-bold text-lg'>
|
||||
{window.location.hostname.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Title heading={4}>
|
||||
{t('授权')} {info?.client?.name || info?.client?.id}
|
||||
</Title>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingBottom: 8 }}>
|
||||
<Button icon={<IconClose />} onClick={onDeny} theme='borderless'>
|
||||
拒绝
|
||||
</Button>
|
||||
<Button icon={<IconTickCircle />} type='primary' onClick={onApprove}>
|
||||
授权
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Divider margin='0' />
|
||||
|
||||
{/* 用户信息 */}
|
||||
<div className='px-5 py-3'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<Text className='block'>
|
||||
<Text strong>{info?.client?.name || info?.client?.id}</Text>
|
||||
{' '}{t('由')}{' '}
|
||||
<Text strong>{info?.client?.domain || t('未知域')}</Text>
|
||||
</Text>
|
||||
<Text type='tertiary' size='small' className='block mt-1'>
|
||||
{t('想要访问你的')} <Text strong>{info?.user?.name || ''}</Text> {t('账户')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button size='small' theme='outline' type='tertiary' onClick={() => {
|
||||
const u = new URL(window.location.origin + '/login');
|
||||
u.searchParams.set('next', '/oauth/consent' + window.location.search);
|
||||
window.location.href = u.toString();
|
||||
}}>
|
||||
{t('切换账户')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin='0' />
|
||||
|
||||
{/* 权限列表 */}
|
||||
<div className='px-5 py-3'>
|
||||
<div className='space-y-2'>
|
||||
{info?.scope_info?.length ? (
|
||||
info.scope_info.map((scope) => (
|
||||
<ScopeItem
|
||||
key={scope.Name}
|
||||
name={scope.Name}
|
||||
description={scope.Description}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className='space-y-1'>
|
||||
{info?.scope_list?.map((name) => (
|
||||
<ScopeItem key={name} name={name} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Meta信息Card */}
|
||||
<Card bordered={false}>
|
||||
<div className='text-center'>
|
||||
<div className='flex flex-wrap justify-center gap-x-2 gap-y-1 items-center'>
|
||||
<Text size='small'>{t('客户端ID')}: {info?.client?.id?.slice(-8) || 'N/A'}</Text>
|
||||
<Dot size={16} />
|
||||
<Text size='small'>{t('类型')}: {info?.client?.type === 'public' ? t('公开应用') : t('机密应用')}</Text>
|
||||
{info?.response_type && (
|
||||
<>
|
||||
<Dot size={16} />
|
||||
<Text size='small'>{t('授权类型')}: {info.response_type === 'code' ? t('授权码') : info.response_type}</Text>
|
||||
</>
|
||||
)}
|
||||
{info?.require_pkce && (
|
||||
<>
|
||||
<Dot size={16} />
|
||||
<Text size='small'>PKCE: {t('已启用')}</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{info?.state && (
|
||||
<div className='mt-2'>
|
||||
<Text type='tertiary' size='small' className='font-mono'>
|
||||
State: {info.state}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Table, Button, Space, Tag, Typography, Popconfirm, Toast } from '@douyinfe/semi-ui';
|
||||
import { IconRefresh, IconDelete, IconPlay } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function JWKSManager() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keys, setKeys] = useState([]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/oauth/keys');
|
||||
if (res?.data?.success) {
|
||||
setKeys(res.data.data || []);
|
||||
} else {
|
||||
showError(res?.data?.message || '获取密钥列表失败');
|
||||
}
|
||||
} catch (e) {
|
||||
showError('获取密钥列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rotate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/oauth/keys/rotate', {});
|
||||
if (res?.data?.success) {
|
||||
showSuccess('签名密钥已轮换:' + res.data.kid);
|
||||
await load();
|
||||
} else {
|
||||
showError(res?.data?.message || '密钥轮换失败');
|
||||
}
|
||||
} catch (e) {
|
||||
showError('密钥轮换失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const del = async (kid) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.delete(`/api/oauth/keys/${kid}`);
|
||||
if (res?.data?.success) {
|
||||
Toast.success('已删除:' + kid);
|
||||
await load();
|
||||
} else {
|
||||
showError(res?.data?.message || '删除失败');
|
||||
}
|
||||
} catch (e) {
|
||||
showError('删除失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'KID',
|
||||
dataIndex: 'kid',
|
||||
render: (kid) => <Text code copyable>{kid}</Text>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
render: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '-'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'current',
|
||||
render: (cur) => (cur ? <Tag color='green'>当前</Tag> : <Tag>历史</Tag>),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
{!r.current && (
|
||||
<Popconfirm
|
||||
title={`确定删除密钥 ${r.kid} ?`}
|
||||
content='删除后使用该 kid 签发的旧令牌仍可被验证(若 JWKS 已被其他方缓存,建议保留一段时间)'
|
||||
okText='删除'
|
||||
onConfirm={() => del(r.kid)}
|
||||
>
|
||||
<Button icon={<IconDelete />} size='small' theme='borderless'>删除</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title='JWKS 管理'
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<IconRefresh />} onClick={load} loading={loading}>刷新</Button>
|
||||
<Button icon={<IconPlay />} type='primary' onClick={rotate} loading={loading}>轮换密钥</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginTop: 10 }}
|
||||
>
|
||||
<Table
|
||||
dataSource={keys}
|
||||
columns={columns}
|
||||
rowKey='kid'
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
empty={<Text type='tertiary'>暂无密钥</Text>}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
/*
|
||||
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' && (
|
||||
<Popconfirm
|
||||
title="确认重新生成客户端密钥?"
|
||||
content={
|
||||
<div>
|
||||
<div>客户端:{record.name}</div>
|
||||
<div style={{ marginTop: 6 }}>操作不可撤销,旧密钥将立即失效。</div>
|
||||
</div>
|
||||
}
|
||||
onConfirm={() => handleRegenerateSecret(record)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="secondary"
|
||||
size="small"
|
||||
>
|
||||
重新生成密钥
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="请再次确认删除该客户端"
|
||||
content={
|
||||
<div>
|
||||
<div>客户端:{record.name}</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--semi-color-danger)' }}>删除后无法恢复,相关 API 调用将立即失效。</div>
|
||||
</div>
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Card, Typography, Button, Space, Steps, Form, Input, Select, Tag, Toast } from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function OAuth2QuickStart({ onChanged }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const origin = useMemo(() => window.location.origin, []);
|
||||
const [client, setClient] = useState({
|
||||
name: 'Default OIDC Client',
|
||||
client_type: 'public',
|
||||
redirect_uris: [origin + '/oauth/oidc', ''],
|
||||
scopes: ['openid', 'profile', 'email', 'api:read'],
|
||||
});
|
||||
|
||||
const applyRecommended = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const ops = [
|
||||
{ key: 'oauth2.enabled', value: 'true' },
|
||||
{ key: 'oauth2.issuer', value: origin },
|
||||
{ key: 'oauth2.allowed_grant_types', value: JSON.stringify(['authorization_code', 'refresh_token', 'client_credentials']) },
|
||||
{ key: 'oauth2.require_pkce', value: 'true' },
|
||||
{ key: 'oauth2.jwt_signing_algorithm', value: 'RS256' },
|
||||
];
|
||||
for (const op of ops) {
|
||||
await API.put('/api/option/', op);
|
||||
}
|
||||
showSuccess('已应用推荐配置');
|
||||
onChanged && onChanged();
|
||||
} catch (e) {
|
||||
showError('应用推荐配置失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureKey = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await API.get('/api/oauth/keys');
|
||||
const list = res?.data?.data || [];
|
||||
if (list.length === 0) {
|
||||
const r = await API.post('/api/oauth/keys/rotate', {});
|
||||
if (r?.data?.success) showSuccess('已初始化签名密钥');
|
||||
} else {
|
||||
const r = await API.post('/api/oauth/keys/rotate', {});
|
||||
if (r?.data?.success) showSuccess('已轮换签名密钥:' + r.data.kid);
|
||||
}
|
||||
} catch (e) {
|
||||
showError('签名密钥操作失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createClient = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const grant_types = client.client_type === 'public'
|
||||
? ['authorization_code', 'refresh_token']
|
||||
: ['authorization_code', 'refresh_token', 'client_credentials'];
|
||||
const payload = {
|
||||
name: client.name,
|
||||
client_type: client.client_type,
|
||||
grant_types,
|
||||
redirect_uris: client.redirect_uris.filter(Boolean),
|
||||
scopes: client.scopes,
|
||||
require_pkce: true,
|
||||
};
|
||||
const res = await API.post('/api/oauth_clients/', payload);
|
||||
if (res?.data?.success) {
|
||||
Toast.success('客户端已创建:' + res.data.client_id);
|
||||
onChanged && onChanged();
|
||||
} else {
|
||||
showError(res?.data?.message || '创建失败');
|
||||
}
|
||||
} catch (e) {
|
||||
showError('创建失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ marginTop: 10 }}>
|
||||
<Title heading={5} style={{ marginBottom: 8 }}>OAuth2 一键初始化</Title>
|
||||
<Text type='tertiary'>按顺序完成以下步骤,系统将自动完成推荐设置、签名密钥准备、客户端创建与回调配置。</Text>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Steps current={-1} type='basic' direction='vertical'>
|
||||
<Steps.Step title='应用推荐配置' description='启用 OAuth2,设置发行人(Issuer)为当前域名,启用授权码+PKCE、刷新令牌、客户端凭证。'>
|
||||
<Button onClick={applyRecommended} loading={busy} style={{ marginTop: 8 }}>一键应用</Button>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag>issuer = {origin}</Tag>{' '}
|
||||
<Tag>grant_types = auth_code / refresh_token / client_credentials</Tag>{' '}
|
||||
<Tag>PKCE = S256</Tag>
|
||||
</div>
|
||||
</Steps.Step>
|
||||
<Steps.Step title='准备签名密钥' description='若无密钥则初始化;如已存在,建议立即轮换以生成新的 kid。'>
|
||||
<Button onClick={ensureKey} loading={busy} style={{ marginTop: 8 }}>初始化/轮换</Button>
|
||||
</Steps.Step>
|
||||
<Steps.Step title='创建 OIDC 客户端' description='创建一个默认客户端,预置常用回调与 scope,可直接用于调试与集成。'>
|
||||
<Form labelPosition='left' labelWidth={120} style={{ marginTop: 8 }}>
|
||||
<Form.Input label='名称' value={client.name} onChange={(v)=>setClient({...client, name: v})} />
|
||||
<Form.Select label='类型' value={client.client_type} onChange={(v)=>setClient({...client, client_type: v})}>
|
||||
<Select.Option value='public'>公开客户端</Select.Option>
|
||||
<Select.Option value='confidential'>机密客户端</Select.Option>
|
||||
</Form.Select>
|
||||
<Form.Input label='回调 URI 1' value={client.redirect_uris[0]} onChange={(v)=>{
|
||||
const arr=[...client.redirect_uris]; arr[0]=v; setClient({...client, redirect_uris: arr});
|
||||
}} />
|
||||
<Form.Input label='回调 URI 2' value={client.redirect_uris[1]} onChange={(v)=>{
|
||||
const arr=[...client.redirect_uris]; arr[1]=v; setClient({...client, redirect_uris: arr});
|
||||
}} />
|
||||
<Form.Select label='Scopes' multiple value={client.scopes} onChange={(v)=>setClient({...client, scopes: v})}>
|
||||
<Select.Option value='openid'>openid</Select.Option>
|
||||
<Select.Option value='profile'>profile</Select.Option>
|
||||
<Select.Option value='email'>email</Select.Option>
|
||||
<Select.Option value='api:read'>api:read</Select.Option>
|
||||
<Select.Option value='api:write'>api:write</Select.Option>
|
||||
<Select.Option value='admin'>admin</Select.Option>
|
||||
</Form.Select>
|
||||
</Form>
|
||||
<Button type='primary' onClick={createClient} loading={busy} style={{ marginTop: 8 }}>创建默认客户端</Button>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
/*
|
||||
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', 'refresh_token'],
|
||||
'oauth2.require_pkce': true,
|
||||
'oauth2.auto_create_user': false,
|
||||
'oauth2.default_user_role': 1,
|
||||
'oauth2.default_user_group': 'default',
|
||||
'oauth2.max_jwks_keys': 3,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const [keysReady, setKeysReady] = useState(true);
|
||||
const [keysLoading, setKeysLoading] = useState(false);
|
||||
|
||||
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');
|
||||
// 只要返回了issuer等关键字段即可视为成功
|
||||
if (res.status === 200 && (res.data.issuer || res.data.authorization_endpoint)) {
|
||||
showSuccess('OAuth2服务器运行正常');
|
||||
} else {
|
||||
showError('OAuth2服务器测试失败');
|
||||
}
|
||||
} 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","refresh_token"]');
|
||||
} catch {
|
||||
currentInputs[key] = ['client_credentials', 'authorization_code', 'refresh_token'];
|
||||
}
|
||||
} 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]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
setKeysLoading(true);
|
||||
const res = await API.get('/api/oauth/keys', { skipErrorHandler: true });
|
||||
const list = res?.data?.data || [];
|
||||
setKeysReady(list.length > 0);
|
||||
} catch {
|
||||
setKeysReady(false);
|
||||
} finally {
|
||||
setKeysLoading(false);
|
||||
}
|
||||
};
|
||||
if (inputs['oauth2.enabled']) {
|
||||
loadKeys();
|
||||
}
|
||||
}, [inputs['oauth2.enabled']]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 移除重复的顶卡片,统一在下方“基础配置”中显示开关与 Issuer */}
|
||||
|
||||
{/* 开关与基础配置 */}
|
||||
<Card style={{ marginTop: 10 }}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
<Form.Section text={'基础配置'}>
|
||||
{!keysReady && inputs['oauth2.enabled'] && (
|
||||
<Banner
|
||||
type='warning'
|
||||
description={<div>
|
||||
<div>尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。</div>
|
||||
<div>签名密钥用于 JWT 令牌的安全签发。</div>
|
||||
</div>}
|
||||
actions={<Button size='small' onClick={() => props?.onOpenJWKS && props.onOpenJWKS()} loading={keysLoading}>打开密钥向导</Button>}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
<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.Switch
|
||||
field='oauth2.enabled'
|
||||
label={t('启用 OAuth2 & SSO')}
|
||||
checkedText='开'
|
||||
uncheckedText='关'
|
||||
value={inputs['oauth2.enabled']}
|
||||
onChange={handleFieldChange('oauth2.enabled')}
|
||||
extraText="开启后将允许以 OAuth2/OIDC 标准进行授权与登录"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field='oauth2.issuer'
|
||||
label={t('发行人 (Issuer)')}
|
||||
placeholder={window.location.origin}
|
||||
value={inputs['oauth2.issuer']}
|
||||
onChange={handleFieldChange('oauth2.issuer')}
|
||||
extraText="为空则按请求自动推断(含 X-Forwarded-Proto)"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={onSubmit} loading={loading}>{t('更新基础配置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{inputs['oauth2.enabled'] && (
|
||||
<>
|
||||
<Card style={{ marginTop: 10 }}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
<Form.Section text={'令牌配置'}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={<div>
|
||||
<div>• OAuth2 服务器提供标准的 API 认证与授权</div>
|
||||
<div>• 支持 Client Credentials、Authorization Code + PKCE 等标准流程</div>
|
||||
<div>• 配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作;一般无需重启</div>
|
||||
<div>• 生产环境务必启用 HTTPS,并妥善管理 JWT 签名密钥</div>
|
||||
</div>}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<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="如需外部文件私钥,可在此指定路径;推荐使用内存密钥 + JWKS 轮换(更安全便捷)"*/}
|
||||
{/* />*/}
|
||||
{/*</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.InputNumber
|
||||
field='oauth2.max_jwks_keys'
|
||||
label='JWKS历史保留上限'
|
||||
min={1}
|
||||
max={10}
|
||||
value={inputs['oauth2.max_jwks_keys']}
|
||||
onChange={handleFieldChange('oauth2.max_jwks_keys')}
|
||||
extraText="轮换后最多保留的历史签名密钥数量(越少越安全,建议 3)"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button onClick={onSubmit} loading={loading}>{t('更新令牌配置')}</Button>
|
||||
<Button type='secondary' onClick={() => props && props.onOpenJWKS && props.onOpenJWKS()}>密钥向导(JWKS)</Button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, Form, Input, Button, Space, Typography, Divider, Toast, Select } from '@douyinfe/semi-ui';
|
||||
import { API } from '../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
async function sha256Base64Url(input) {
|
||||
const enc = new TextEncoder();
|
||||
const data = enc.encode(input);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
const bytes = new Uint8Array(hash);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function randomString(len = 43) {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
let res = '';
|
||||
const array = new Uint32Array(len);
|
||||
crypto.getRandomValues(array);
|
||||
for (let i = 0; i < len; i++) res += charset[array[i] % charset.length];
|
||||
return res;
|
||||
}
|
||||
|
||||
export default function OAuth2Tools() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [server, setServer] = useState({});
|
||||
const [values, setValues] = useState({
|
||||
authorization_endpoint: '',
|
||||
token_endpoint: '',
|
||||
client_id: '',
|
||||
redirect_uri: window.location.origin + '/oauth/oidc',
|
||||
scope: 'openid profile email',
|
||||
response_type: 'code',
|
||||
code_verifier: '',
|
||||
code_challenge: '',
|
||||
code_challenge_method: 'S256',
|
||||
state: '',
|
||||
nonce: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await API.get('/api/oauth/server-info');
|
||||
if (res?.data) {
|
||||
const d = res.data;
|
||||
setServer(d);
|
||||
setValues((v) => ({
|
||||
...v,
|
||||
authorization_endpoint: d.authorization_endpoint,
|
||||
token_endpoint: d.token_endpoint,
|
||||
}));
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const buildAuthorizeURL = () => {
|
||||
const u = new URL(values.authorization_endpoint || (server.issuer + '/oauth/authorize'));
|
||||
u.searchParams.set('response_type', values.response_type || 'code');
|
||||
u.searchParams.set('client_id', values.client_id);
|
||||
u.searchParams.set('redirect_uri', values.redirect_uri);
|
||||
u.searchParams.set('scope', values.scope);
|
||||
if (values.state) u.searchParams.set('state', values.state);
|
||||
if (values.nonce) u.searchParams.set('nonce', values.nonce);
|
||||
if (values.code_challenge) {
|
||||
u.searchParams.set('code_challenge', values.code_challenge);
|
||||
u.searchParams.set('code_challenge_method', values.code_challenge_method || 'S256');
|
||||
}
|
||||
return u.toString();
|
||||
};
|
||||
|
||||
const copy = async (text, tip = '已复制') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
Toast.success(tip);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const genVerifier = async () => {
|
||||
const v = randomString(64);
|
||||
const c = await sha256Base64Url(v);
|
||||
setValues((val) => ({ ...val, code_verifier: v, code_challenge: c }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ marginTop: 10 }} title='OAuth2 调试助手'>
|
||||
<Form labelPosition='left' labelWidth={140}>
|
||||
<Form.Input field='authorization_endpoint' label='Authorize URL' value={values.authorization_endpoint} onChange={(v)=>setValues({...values, authorization_endpoint: v})} />
|
||||
<Form.Input field='token_endpoint' label='Token URL' value={values.token_endpoint} onChange={(v)=>setValues({...values, token_endpoint: v})} />
|
||||
<Form.Input field='client_id' label='Client ID' placeholder='输入 client_id' value={values.client_id} onChange={(v)=>setValues({...values, client_id: v})} />
|
||||
<Form.Input field='redirect_uri' label='Redirect URI' value={values.redirect_uri} onChange={(v)=>setValues({...values, redirect_uri: v})} />
|
||||
<Form.Input field='scope' label='Scope' value={values.scope} onChange={(v)=>setValues({...values, scope: v})} />
|
||||
<Form.Select field='code_challenge_method' label='PKCE 方法' value={values.code_challenge_method} onChange={(v)=>setValues({...values, code_challenge_method: v})}>
|
||||
<Select.Option value='S256'>S256</Select.Option>
|
||||
</Form.Select>
|
||||
<Form.Input field='code_verifier' label='Code Verifier' value={values.code_verifier} onChange={(v)=>setValues({...values, code_verifier: v})} suffix={
|
||||
<Button size='small' onClick={genVerifier}>生成</Button>
|
||||
} />
|
||||
<Form.Input field='code_challenge' label='Code Challenge' value={values.code_challenge} onChange={(v)=>setValues({...values, code_challenge: v})} />
|
||||
<Form.Input field='state' label='State' value={values.state} onChange={(v)=>setValues({...values, state: v})} suffix={<Button size='small' onClick={()=>setValues({...values, state: randomString(16)})}>随机</Button>} />
|
||||
<Form.Input field='nonce' label='Nonce' value={values.nonce} onChange={(v)=>setValues({...values, nonce: v})} suffix={<Button size='small' onClick={()=>setValues({...values, nonce: randomString(16)})}>随机</Button>} />
|
||||
</Form>
|
||||
<Divider />
|
||||
<Space>
|
||||
<Button onClick={()=>window.open(buildAuthorizeURL(), '_blank')}>打开授权URL</Button>
|
||||
<Button onClick={()=>copy(buildAuthorizeURL(), '授权URL已复制')}>复制授权URL</Button>
|
||||
<Button onClick={()=>copy(JSON.stringify({
|
||||
authorize_url: values.authorization_endpoint,
|
||||
token_url: values.token_endpoint,
|
||||
client_id: values.client_id,
|
||||
redirect_uri: values.redirect_uri,
|
||||
scope: values.scope,
|
||||
code_challenge_method: values.code_challenge_method,
|
||||
code_verifier: values.code_verifier,
|
||||
code_challenge: values.code_challenge,
|
||||
state: values.state,
|
||||
nonce: values.nonce,
|
||||
}, null, 2), 'oauthdebugger参数已复制')}>复制 oauthdebugger 参数</Button>
|
||||
</Space>
|
||||
<Text type='tertiary' style={{ display: 'block', marginTop: 8 }}>
|
||||
提示:将上述参数粘贴到 oauthdebugger.com,或直接打开授权URL完成授权后回调。
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user