Jelajahi Sumber

✨ 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
t0ng7u 3 bulan lalu
induk
melakukan
359dbc9d94

+ 135 - 0
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 <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+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;

+ 4 - 5
web/src/components/settings/oauth2/OAuth2ClientSettings.jsx

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

+ 1 - 1
web/src/components/settings/oauth2/OAuth2ServerSettings.jsx

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

+ 203 - 118
web/src/components/settings/oauth2/modals/JWKSManagerModal.jsx

@@ -18,7 +18,6 @@ For commercial licensing, please contact [email protected]
 */
 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>
   );
 }

+ 5 - 3
web/src/components/settings/oauth2/modals/SecretDisplayModal.jsx

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

+ 31 - 2
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"
 }