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:
t0ng7u
2025-09-20 17:01:00 +08:00
parent 4a02ab23ce
commit 418ce449b7
14 changed files with 1481 additions and 1530 deletions

View File

@@ -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>
);
};

View 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>
);
}

View 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>
);
}

View File

@@ -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">openidOIDC 基础身份</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">openidOIDC 基础身份</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 }}>
PKCEProof 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('PKCEProof 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>
</>

View File

@@ -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 }}
>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 CredentialsAuthorization 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>
);
}

View File

@@ -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>
);
}