From 359dbc9d94da7fcf9287297346103cc5206deeac Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 23 Sep 2025 01:16:17 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(oauth2):=20enhance=20JWKS=20ma?= =?UTF-8?q?nager=20modal=20with=20improved=20UX=20and=20i18n=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/common/ui/ResponsiveModal.jsx | 135 ++++++++ .../settings/oauth2/OAuth2ClientSettings.jsx | 9 +- .../settings/oauth2/OAuth2ServerSettings.jsx | 2 +- .../oauth2/modals/JWKSManagerModal.jsx | 321 +++++++++++------- .../oauth2/modals/SecretDisplayModal.jsx | 8 +- web/src/i18n/locales/en.json | 33 +- 6 files changed, 379 insertions(+), 129 deletions(-) create mode 100644 web/src/components/common/ui/ResponsiveModal.jsx diff --git a/web/src/components/common/ui/ResponsiveModal.jsx b/web/src/components/common/ui/ResponsiveModal.jsx new file mode 100644 index 000000000..dee39a9c1 --- /dev/null +++ b/web/src/components/common/ui/ResponsiveModal.jsx @@ -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 . + +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 ( +
+ {title && ( + + {title} + + )} + {headerActions && headerActions.length > 0 && ( +
+ {headerActions.map((action, index) => ( + {action} + ))} +
+ )} +
+ ); + }; + + // 计算模态框宽度 + const getModalWidth = () => { + if (typeof width === 'object') { + return isMobile ? width.mobile : width.desktop; + } + return width; + }; + + return ( + } + onCancel={onCancel} + footer={footer} + width={getModalWidth()} + className={`!top-12 ${className}`} + {...props} + > + {children} + + ); +}; + +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; diff --git a/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx b/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx index 7b9971bac..74f432801 100644 --- a/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx +++ b/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx @@ -343,13 +343,12 @@ export default function OAuth2ClientSettings() { } title={t('暂无OAuth2客户端')} - description={t('还没有创建任何客户端,点击下方按钮创建第一个客户端')} + description={t( + '还没有创建任何客户端,点击下方按钮创建第一个客户端', + )} style={{ padding: 30 }} > - diff --git a/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx b/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx index eaa788d27..3b84dc98b 100644 --- a/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx +++ b/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx @@ -220,7 +220,7 @@ export default function OAuth2ServerSettings(props) { >
- {t('OAuth2 & SSO 管理')} + {t('OAuth2 服务端管理')} {isEnabled ? ( serverInfo ? ( { 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 ? {t('当前')} : {t('历史')}, + cur ? ( + + {t('当前')} + + ) : ( + {t('历史')} + ), }, { title: t('操作'), @@ -187,7 +219,7 @@ export default function JWKSManagerModal({ visible, onClose }) { okText={t('删除')} onConfirm={() => del(r.kid)} > - @@ -197,109 +229,68 @@ export default function JWKSManagerModal({ visible, onClose }) { }, ]; - return ( - - - - , + - - - - - {showGenerate && ( -
, + ]; + } + + if (activeTab === OPERATION_MODES.IMPORT) { + return [ + -
- - - {t( - '建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。', - )} - -
- )} - {showImport && ( -
, + ]; + } + + if (activeTab === OPERATION_MODES.GENERATE) { + return [ + -
- - - {t( - '建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。', - )} - - - )} + {t('生成并生效')} + , + ]; + } + + return []; + }; + + // 渲染密钥列表视图 + const renderKeysView = () => ( + + {t( + '提示:当前密钥用于签发 JWT 令牌。建议定期轮换密钥以提升安全性。只有历史密钥可以删除。', + )} + + } + > {t('暂无密钥')}} /> - + + ); + + // 渲染导入 PEM 私钥视图 + const renderImportView = () => ( + + {t( + '建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。请确保私钥来源可信。', + )} + + } + > +
+ + + +
+ ); + + // 渲染生成 PEM 文件视图 + const renderGenerateView = () => ( + + {t( + '建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。', + )} + + } + > +
+ + + +
+ ); + + return ( + + + + {renderKeysView()} + + + {renderImportView()} + + + {renderGenerateView()} + + + ); } diff --git a/web/src/components/settings/oauth2/modals/SecretDisplayModal.jsx b/web/src/components/settings/oauth2/modals/SecretDisplayModal.jsx index 10a5b2160..3502881ab 100644 --- a/web/src/components/settings/oauth2/modals/SecretDisplayModal.jsx +++ b/web/src/components/settings/oauth2/modals/SecretDisplayModal.jsx @@ -45,9 +45,11 @@ const SecretDisplayModal = ({ visible, onClose, secret }) => { )} className='mb-5 !rounded-lg' /> - - {secret} - +
+ + {secret} + +
); }; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1e95054a7..b74f30ea9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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" }