feat(oauth2): enhance JWKS manager modal with improved UX and i18n support

- Refactor JWKSManagerModal with tab-based navigation using Card components
- Add comprehensive i18n support with English translations for all text
- Optimize header actions: refresh button only appears in key list tab
- Improve responsive design using ResponsiveModal component
- Move cautionary text from bottom to Card titles for better visibility
- Update button styles: danger type for delete, circle shape for status tags
- Standardize code formatting (single quotes, multiline formatting)
- Enhance user workflow: separate Import PEM and Generate PEM operations
- Remove redundant cancel buttons as modal already has close icon

Breaking changes: None
Affects: JWKS key management, OAuth2 settings UI
This commit is contained in:
t0ng7u
2025-09-23 01:16:17 +08:00
parent e157ea6ba2
commit 359dbc9d94
6 changed files with 379 additions and 129 deletions

View File

@@ -0,0 +1,135 @@
/*
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 from 'react';
import { Modal, Typography } from '@douyinfe/semi-ui';
import PropTypes from 'prop-types';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
const { Title } = Typography;
/**
* ResponsiveModal 响应式模态框组件
*
* 特性:
* - 响应式布局:移动端和桌面端不同的宽度和布局
* - 自定义头部:标题左对齐,操作按钮右对齐,移动端自动换行
* - Tailwind CSS 样式支持
* - 保持原 Modal 组件的所有功能
*/
const ResponsiveModal = ({
visible,
onCancel,
title,
headerActions = [],
children,
width = { mobile: '95%', desktop: 600 },
className = '',
footer = null,
titleProps = {},
headerClassName = '',
actionsClassName = '',
...props
}) => {
const isMobile = useIsMobile();
// 自定义 Header 组件
const CustomHeader = () => {
if (!title && (!headerActions || headerActions.length === 0)) return null;
return (
<div
className={`flex w-full gap-3 justify-between ${
isMobile ? 'flex-col items-start' : 'flex-row items-center'
} ${headerClassName}`}
>
{title && (
<Title heading={5} className='m-0 min-w-fit' {...titleProps}>
{title}
</Title>
)}
{headerActions && headerActions.length > 0 && (
<div
className={`flex flex-wrap gap-2 items-center ${
isMobile ? 'w-full justify-start' : 'w-auto justify-end'
} ${actionsClassName}`}
>
{headerActions.map((action, index) => (
<React.Fragment key={index}>{action}</React.Fragment>
))}
</div>
)}
</div>
);
};
// 计算模态框宽度
const getModalWidth = () => {
if (typeof width === 'object') {
return isMobile ? width.mobile : width.desktop;
}
return width;
};
return (
<Modal
visible={visible}
title={<CustomHeader />}
onCancel={onCancel}
footer={footer}
width={getModalWidth()}
className={`!top-12 ${className}`}
{...props}
>
{children}
</Modal>
);
};
ResponsiveModal.propTypes = {
// Modal 基础属性
visible: PropTypes.bool.isRequired,
onCancel: PropTypes.func.isRequired,
children: PropTypes.node,
// 自定义头部
title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
headerActions: PropTypes.arrayOf(PropTypes.node),
// 样式和布局
width: PropTypes.oneOfType([
PropTypes.number,
PropTypes.string,
PropTypes.shape({
mobile: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
desktop: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
}),
]),
className: PropTypes.string,
footer: PropTypes.node,
// 标题自定义属性
titleProps: PropTypes.object,
// 自定义 CSS 类
headerClassName: PropTypes.string,
actionsClassName: PropTypes.string,
};
export default ResponsiveModal;

View File

@@ -343,13 +343,12 @@ export default function OAuth2ClientSettings() {
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
title={t('暂无OAuth2客户端')}
description={t('还没有创建任何客户端,点击下方按钮创建第一个客户端')}
description={t(
'还没有创建任何客户端,点击下方按钮创建第一个客户端',
)}
style={{ padding: 30 }}
>
<Button
type='primary'
onClick={() => setShowCreateModal(true)}
>
<Button type='primary' onClick={() => setShowCreateModal(true)}>
{t('创建第一个客户端')}
</Button>
</Empty>

View File

@@ -220,7 +220,7 @@ export default function OAuth2ServerSettings(props) {
>
<div className='flex items-center'>
<Server size={18} className='mr-2' />
<Text strong>{t('OAuth2 & SSO 管理')}</Text>
<Text strong>{t('OAuth2 服务端管理')}</Text>
{isEnabled ? (
serverInfo ? (
<Badge

View File

@@ -18,7 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import {
Modal,
Table,
Button,
Space,
@@ -27,19 +26,28 @@ import {
Popconfirm,
Toast,
Form,
TextArea,
Divider,
Input,
Card,
Tabs,
TabPane,
} from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import ResponsiveModal from '../../../common/ui/ResponsiveModal';
const { Text } = Typography;
// 操作模式枚举
const OPERATION_MODES = {
VIEW: 'view',
IMPORT: 'import',
GENERATE: 'generate',
};
export default function JWKSManagerModal({ visible, onClose }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [keys, setKeys] = useState([]);
const [activeTab, setActiveTab] = useState(OPERATION_MODES.VIEW);
const load = async () => {
setLoading(true);
@@ -84,9 +92,32 @@ export default function JWKSManagerModal({ visible, onClose }) {
}
};
// Import PEM state
const [pem, setPem] = useState('');
const [customKid, setCustomKid] = useState('');
// Generate PEM file state
const [genPath, setGenPath] = useState('/etc/new-api/oauth2-private.pem');
const [genKid, setGenKid] = useState('');
// 重置表单数据
const resetForms = () => {
setPem('');
setCustomKid('');
setGenKid('');
};
useEffect(() => {
if (visible) load();
if (visible) {
load();
// 重置到主视图
setActiveTab(OPERATION_MODES.VIEW);
} else {
// 模态框关闭时重置表单数据
resetForms();
}
}, [visible]);
useEffect(() => {
if (!visible) return;
(async () => {
@@ -98,10 +129,7 @@ export default function JWKSManagerModal({ visible, onClose }) {
})();
}, [visible]);
// Import PEM state
const [showImport, setShowImport] = useState(false);
const [pem, setPem] = useState('');
const [customKid, setCustomKid] = useState('');
// 导入 PEM 私钥
const importPem = async () => {
if (!pem.trim()) return Toast.warning(t('请粘贴 PEM 私钥'));
setLoading(true);
@@ -114,9 +142,8 @@ export default function JWKSManagerModal({ visible, onClose }) {
Toast.success(
t('已导入私钥并切换到 kid={{kid}}', { kid: res.data.kid }),
);
setPem('');
setCustomKid('');
setShowImport(false);
resetForms();
setActiveTab(OPERATION_MODES.VIEW);
await load();
} else {
Toast.error(res?.data?.message || t('导入失败'));
@@ -128,10 +155,7 @@ export default function JWKSManagerModal({ visible, onClose }) {
}
};
// Generate PEM file state
const [showGenerate, setShowGenerate] = useState(false);
const [genPath, setGenPath] = useState('/etc/new-api/oauth2-private.pem');
const [genKid, setGenKid] = useState('');
// 生成 PEM 文件
const generatePemFile = async () => {
if (!genPath.trim()) return Toast.warning(t('请填写保存路径'));
setLoading(true);
@@ -142,6 +166,8 @@ export default function JWKSManagerModal({ visible, onClose }) {
});
if (res?.data?.success) {
Toast.success(t('已生成并生效:{{path}}', { path: res.data.path }));
resetForms();
setActiveTab(OPERATION_MODES.VIEW);
await load();
} else {
Toast.error(res?.data?.message || t('生成失败'));
@@ -172,7 +198,13 @@ export default function JWKSManagerModal({ visible, onClose }) {
title: t('状态'),
dataIndex: 'current',
render: (cur) =>
cur ? <Tag color='green'>{t('当前')}</Tag> : <Tag>{t('历史')}</Tag>,
cur ? (
<Tag color='green' shape='circle'>
{t('当前')}
</Tag>
) : (
<Tag shape='circle'>{t('历史')}</Tag>
),
},
{
title: t('操作'),
@@ -187,7 +219,7 @@ export default function JWKSManagerModal({ visible, onClose }) {
okText={t('删除')}
onConfirm={() => del(r.kid)}
>
<Button size='small' theme='borderless'>
<Button size='small' type='danger'>
{t('删除')}
</Button>
</Popconfirm>
@@ -197,109 +229,68 @@ export default function JWKSManagerModal({ visible, onClose }) {
},
];
return (
<Modal
visible={visible}
title={t('JWKS 管理')}
onCancel={onClose}
footer={null}
width={820}
style={{ top: 48 }}
>
<Space style={{ marginBottom: 8 }}>
<Button onClick={load} loading={loading}>
// 头部操作按钮 - 根据当前标签页动态生成
const getHeaderActions = () => {
if (activeTab === OPERATION_MODES.VIEW) {
return [
<Button key='refresh' onClick={load} loading={loading} size='small'>
{t('刷新')}
</Button>
<Button type='primary' onClick={rotate} loading={loading}>
</Button>,
<Button
key='rotate'
type='primary'
onClick={rotate}
loading={loading}
size='small'
>
{t('轮换密钥')}
</Button>
<Button onClick={() => setShowImport(!showImport)}>
{t('导入 PEM 私钥')}
</Button>
<Button onClick={() => setShowGenerate(!showGenerate)}>
{t('生成 PEM 文件')}
</Button>
<Button onClick={onClose}>{t('关闭')}</Button>
</Space>
{showGenerate && (
<div
style={{
border: '1px solid var(--semi-color-border)',
borderRadius: 6,
padding: 12,
marginBottom: 12,
}}
</Button>,
];
}
if (activeTab === OPERATION_MODES.IMPORT) {
return [
<Button
key='import'
type='primary'
onClick={importPem}
loading={loading}
size='small'
>
<Form labelPosition='left' labelWidth={120}>
<Form.Input
field='path'
label={t('保存路径')}
value={genPath}
onChange={setGenPath}
placeholder='/secure/path/oauth2-private.pem'
/>
<Form.Input
field='genKid'
label={t('自定义 KID')}
value={genKid}
onChange={setGenKid}
placeholder={t('可留空自动生成')}
/>
</Form>
<div style={{ marginTop: 8 }}>
<Button type='primary' onClick={generatePemFile} loading={loading}>
{t('生成并生效')}
</Button>
</div>
<Divider margin='12px' />
<Text type='tertiary'>
{t(
'建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600并妥善备份。',
)}
</Text>
</div>
)}
{showImport && (
<div
style={{
border: '1px solid var(--semi-color-border)',
borderRadius: 6,
padding: 12,
marginBottom: 12,
}}
{t('导入并生效')}
</Button>,
];
}
if (activeTab === OPERATION_MODES.GENERATE) {
return [
<Button
key='generate'
type='primary'
onClick={generatePemFile}
loading={loading}
size='small'
>
<Form labelPosition='left' labelWidth={120}>
<Form.Input
field='kid'
label={t('自定义 KID')}
placeholder={t('可留空自动生成')}
value={customKid}
onChange={setCustomKid}
/>
<Form.TextArea
field='pem'
label={t('PEM 私钥')}
value={pem}
onChange={setPem}
rows={6}
placeholder={
'-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----'
}
/>
</Form>
<div style={{ marginTop: 8 }}>
<Button type='primary' onClick={importPem} loading={loading}>
{t('导入并生效')}
</Button>
</div>
<Divider margin='12px' />
<Text type='tertiary'>
{t(
'建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。',
)}
</Text>
</div>
)}
{t('生成并生效')}
</Button>,
];
}
return [];
};
// 渲染密钥列表视图
const renderKeysView = () => (
<Card
className='!rounded-lg'
title={
<Text className='text-blue-700 dark:text-blue-300'>
{t(
'提示:当前密钥用于签发 JWT 令牌。建议定期轮换密钥以提升安全性。只有历史密钥可以删除。',
)}
</Text>
}
>
<Table
dataSource={keys}
columns={columns}
@@ -308,6 +299,100 @@ export default function JWKSManagerModal({ visible, onClose }) {
pagination={false}
empty={<Text type='tertiary'>{t('暂无密钥')}</Text>}
/>
</Modal>
</Card>
);
// 渲染导入 PEM 私钥视图
const renderImportView = () => (
<Card
className='!rounded-lg'
title={
<Text className='text-yellow-700 dark:text-yellow-300'>
{t(
'建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。请确保私钥来源可信。',
)}
</Text>
}
>
<Form labelPosition='left' labelWidth={120}>
<Form.Input
field='kid'
label={t('自定义 KID')}
placeholder={t('可留空自动生成')}
value={customKid}
onChange={setCustomKid}
/>
<Form.TextArea
field='pem'
label={t('PEM 私钥')}
value={pem}
onChange={setPem}
rows={8}
placeholder={
'-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----'
}
/>
</Form>
</Card>
);
// 渲染生成 PEM 文件视图
const renderGenerateView = () => (
<Card
className='!rounded-lg'
title={
<Text className='text-orange-700 dark:text-orange-300'>
{t(
'建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600并妥善备份。',
)}
</Text>
}
>
<Form labelPosition='left' labelWidth={120}>
<Form.Input
field='path'
label={t('保存路径')}
value={genPath}
onChange={setGenPath}
placeholder='/secure/path/oauth2-private.pem'
/>
<Form.Input
field='genKid'
label={t('自定义 KID')}
value={genKid}
onChange={setGenKid}
placeholder={t('可留空自动生成')}
/>
</Form>
</Card>
);
return (
<ResponsiveModal
visible={visible}
title={t('JWKS 管理')}
headerActions={getHeaderActions()}
onCancel={onClose}
footer={null}
width={{ mobile: '95%', desktop: 800 }}
>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
type='card'
size='medium'
className='!-mt-2'
>
<TabPane tab={t('密钥列表')} itemKey={OPERATION_MODES.VIEW}>
{renderKeysView()}
</TabPane>
<TabPane tab={t('导入 PEM 私钥')} itemKey={OPERATION_MODES.IMPORT}>
{renderImportView()}
</TabPane>
<TabPane tab={t('生成 PEM 文件')} itemKey={OPERATION_MODES.GENERATE}>
{renderGenerateView()}
</TabPane>
</Tabs>
</ResponsiveModal>
);
}

View File

@@ -45,9 +45,11 @@ const SecretDisplayModal = ({ visible, onClose, secret }) => {
)}
className='mb-5 !rounded-lg'
/>
<Text code copyable>
{secret}
</Text>
<div className='flex justify-center items-center'>
<Text code copyable>
{secret}
</Text>
</div>
</Modal>
);
};

View File

@@ -2137,7 +2137,7 @@
"客户端密钥已重新生成": "Client secret regenerated",
"我已复制保存": "I have copied and saved",
"新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。": "The new client secret is shown below. Copy and save it now. You will not be able to view it again after closing this window.",
"OAuth2 & SSO 管理": "OAuth2 & SSO Management",
"OAuth2 服务端管理": "OAuth2 Server",
"运行正常": "Healthy",
"配置中": "Configuring",
"保存配置": "Save Configuration",
@@ -2177,5 +2177,34 @@
"客户端名称": "Client Name",
"暂无描述": "No description",
"OAuth2 服务器配置": "OAuth2 Server Configuration",
"JWKS 密钥集": "JWKS Key Set"
"JWKS 密钥集": "JWKS Key Set",
"获取密钥列表失败": "Failed to fetch key list",
"签名密钥已轮换:{{kid}}": "Signing key rotated: {{kid}}",
"密钥轮换失败": "Key rotation failed",
"已删除:{{kid}}": "Deleted: {{kid}}",
"请粘贴 PEM 私钥": "Please paste PEM private key",
"已导入私钥并切换到 kid={{kid}}": "Private key imported and switched to kid={{kid}}",
"导入失败": "Import failed",
"请填写保存路径": "Please enter the save path",
"已生成并生效:{{path}}": "Generated and applied: {{path}}",
"生成失败": "Generation failed",
"当前": "Current",
"历史": "History",
"确定删除密钥 {{kid}} ": "Confirm delete key {{kid}}?",
"删除后使用该 kid 签发的旧令牌仍可被验证(外部 JWKS 缓存可能仍保留)": "Tokens issued with this kid may still be verifiable (external JWKS caches may retain the key)",
"轮换密钥": "Rotate key",
"导入并生效": "Import and apply",
"生成并生效": "Generate and apply",
"提示:当前密钥用于签发 JWT 令牌。建议定期轮换密钥以提升安全性。只有历史密钥可以删除。": "Tip: The current key is used to sign JWT tokens. Rotate keys regularly for security. Only historical keys can be deleted.",
"暂无密钥": "No keys yet",
"建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。请确保私钥来源可信。": "Recommendation: Prefer in-memory signing keys with JWKS rotation; import external private keys only when required for compliance. Ensure the private key source is trusted.",
"自定义 KID": "Custom KID",
"可留空自动生成": "Optional, auto-generate if empty",
"PEM 私钥": "PEM private key",
"建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600并妥善备份。": "Recommendation: Use file-based private keys only when required for compliance. Ensure directory permissions are secure (0600 recommended) and back up properly.",
"保存路径": "Save path",
"JWKS 管理": "JWKS Management",
"密钥列表": "Key list",
"导入 PEM 私钥": "Import PEM private key",
"生成 PEM 文件": "Generate PEM file"
}