Files
new-api/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx
t0ng7u 81272da9ac ♻️ refactor(oauth2): restructure OAuth2 client settings UI and extract modal components
- **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.
2025-09-20 22:52:50 +08:00

488 lines
16 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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>
);
}