mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-22 08:18:38 +00:00
- **UI Restructuring:**
- Separate client info into individual table columns (name, ID, description)
- Replace icon-only action buttons with text labels for better UX
- Adjust table scroll width from 1000px to 1200px for new column layout
- Remove unnecessary Tooltip wrappers and Lucide icons (Edit, Key, Trash2)
- **Component Architecture:**
- Extract all modal dialogs into separate reusable components:
* SecretDisplayModal.jsx - for displaying regenerated client secrets
* ServerInfoModal.jsx - for OAuth2 server configuration info
* JWKSInfoModal.jsx - for JWKS key set information
- Simplify main component by removing ~60 lines of inline modal code
- Implement proper state management for each modal component
- **Code Quality:**
- Remove unused imports and clean up component dependencies
- Consolidate modal logic into dedicated components with error handling
- Improve code maintainability and reusability across the application
- **Internationalization:**
- Add English translation for '客户端名称': 'Client Name'
- Remove duplicate translation keys to fix linter warnings
- Ensure all new components support full i18n functionality
- **User Experience:**
- Enhance table readability with dedicated columns for each data type
- Maintain copyable client ID functionality in separate column
- Improve action button accessibility with clear text labels
- Add loading states and proper error handling in modal components
This refactoring improves code organization, enhances user experience, and follows React best practices for component composition and separation of concerns.
488 lines
16 KiB
JavaScript
488 lines
16 KiB
JavaScript
/*
|
||
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,
|
||
Badge,
|
||
Divider,
|
||
} from '@douyinfe/semi-ui';
|
||
import { Server } from 'lucide-react';
|
||
import JWKSManagerModal from './modals/JWKSManagerModal';
|
||
import {
|
||
compareObjects,
|
||
API,
|
||
showError,
|
||
showSuccess,
|
||
showWarning,
|
||
} from '../../../helpers';
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
const { 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 enabledRef = useRef(inputs['oauth2.enabled']);
|
||
|
||
// 模态框状态
|
||
const [jwksVisible, setJwksVisible] = 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 (silent = true) => {
|
||
// 未启用时不触发测试,避免 404
|
||
if (!enabledRef.current) return;
|
||
try {
|
||
const res = await API.get('/api/oauth/server-info', {
|
||
skipErrorHandler: true,
|
||
});
|
||
if (!enabledRef.current) return;
|
||
if (
|
||
res.status === 200 &&
|
||
(res.data.issuer || res.data.authorization_endpoint)
|
||
) {
|
||
if (!silent) showSuccess('OAuth2服务器运行正常');
|
||
setServerInfo(res.data);
|
||
} else {
|
||
if (!enabledRef.current) return;
|
||
if (!silent) showError('OAuth2服务器测试失败');
|
||
}
|
||
} catch (error) {
|
||
if (!enabledRef.current) return;
|
||
if (!silent) 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(() => {
|
||
enabledRef.current = inputs['oauth2.enabled'];
|
||
}, [inputs['oauth2.enabled']]);
|
||
|
||
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(true);
|
||
} else {
|
||
// 禁用时清理状态,避免残留状态与不必要的请求
|
||
setKeysReady(true);
|
||
setServerInfo(null);
|
||
setKeysLoading(false);
|
||
}
|
||
}, [inputs['oauth2.enabled']]);
|
||
|
||
const isEnabled = inputs['oauth2.enabled'];
|
||
|
||
return (
|
||
<div>
|
||
{/* OAuth2 服务端管理 */}
|
||
<Card
|
||
className='!rounded-2xl shadow-sm border-0'
|
||
style={{ marginTop: 10 }}
|
||
title={
|
||
<div
|
||
className='flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-3 sm:gap-0'
|
||
style={{ paddingRight: '8px' }}
|
||
>
|
||
<div className='flex items-center'>
|
||
<Server size={18} className='mr-2' />
|
||
<Text strong>{t('OAuth2 & SSO 管理')}</Text>
|
||
{isEnabled ? (
|
||
serverInfo ? (
|
||
<Badge
|
||
count={t('运行正常')}
|
||
type='success'
|
||
style={{ marginLeft: 8 }}
|
||
/>
|
||
) : (
|
||
<Badge
|
||
count={t('配置中')}
|
||
type='warning'
|
||
style={{ marginLeft: 8 }}
|
||
/>
|
||
)
|
||
) : (
|
||
<Badge
|
||
count={t('未启用')}
|
||
type='tertiary'
|
||
style={{ marginLeft: 8 }}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className='flex items-center gap-2 sm:flex-shrink-0'>
|
||
<Button
|
||
type='primary'
|
||
onClick={onSubmit}
|
||
loading={loading}
|
||
size='small'
|
||
>
|
||
{t('保存配置')}
|
||
</Button>
|
||
{isEnabled && (
|
||
<Button
|
||
type='secondary'
|
||
onClick={() => setJwksVisible(true)}
|
||
size='small'
|
||
>
|
||
{t('密钥管理')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
}
|
||
>
|
||
<Form
|
||
initValues={inputs}
|
||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||
>
|
||
{!keysReady && isEnabled && (
|
||
<Banner
|
||
type='warning'
|
||
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={t('启用 OAuth2 & SSO')}
|
||
value={inputs['oauth2.enabled']}
|
||
onChange={handleFieldChange('oauth2.enabled')}
|
||
extraText={t('开启后将允许以 OAuth2/OIDC 标准进行授权与登录')}
|
||
/>
|
||
</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>
|
||
|
||
{/* 令牌配置 */}
|
||
<Divider margin='24px'>{t('令牌配置')}</Divider>
|
||
|
||
<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%',
|
||
opacity: isEnabled ? 1 : 0.5,
|
||
}}
|
||
disabled={!isEnabled}
|
||
/>
|
||
</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%',
|
||
opacity: isEnabled ? 1 : 0.5,
|
||
}}
|
||
disabled={!isEnabled}
|
||
/>
|
||
</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%',
|
||
opacity: isEnabled ? 1 : 0.5,
|
||
}}
|
||
disabled={!isEnabled}
|
||
/>
|
||
</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%',
|
||
opacity: isEnabled ? 1 : 0.5,
|
||
}}
|
||
disabled={!isEnabled}
|
||
>
|
||
<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%',
|
||
opacity: isEnabled ? 1 : 0.5,
|
||
}}
|
||
disabled={!isEnabled}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* 授权配置 */}
|
||
<Divider margin='24px'>{t('授权配置')}</Divider>
|
||
|
||
<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%',
|
||
opacity: isEnabled ? 1 : 0.5,
|
||
}}
|
||
disabled={!isEnabled}
|
||
>
|
||
<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验证')}
|
||
value={inputs['oauth2.require_pkce']}
|
||
onChange={handleFieldChange('oauth2.require_pkce')}
|
||
extraText={t('为授权码流程强制启用PKCE,提高安全性')}
|
||
disabled={!isEnabled}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
|
||
<div style={{ marginTop: 16 }}>
|
||
<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>
|
||
</div>
|
||
</Form>
|
||
</Card>
|
||
|
||
{/* 模态框 */}
|
||
<JWKSManagerModal
|
||
visible={jwksVisible}
|
||
onClose={() => setJwksVisible(false)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|