Переглянути джерело

✨ feat(oauth): redesign consent page with GitHub-style UI and improved UX

- Redesign OAuth consent page layout with centered card design
- Implement GitHub-style authorization flow presentation
- Add application popover with detailed information on hover
- Replace generic icons with scope-specific icons (email, profile, admin, etc.)
- Integrate i18n support for all hardcoded strings
- Optimize permission display with encapsulated ScopeItem component
- Improve visual hierarchy with Semi UI Divider components
- Unify avatar sizes and implement dynamic color generation
- Move action buttons and redirect info to card footer
- Add separate meta information card for technical details
- Remove redundant color styles to rely on Semi UI theming
- Enhance user account section with clearer GitHub-style messaging
- Replace dot separators with Lucide icons for better visual consistency
- Add site logo with fallback mechanism for branding
- Implement responsive design with Tailwind CSS utilities

This redesign significantly improves the OAuth consent experience by following
modern UI patterns and providing clearer information hierarchy for users.
t0ng7u 3 місяців тому
батько
коміт
418ce449b7

+ 15 - 34
web/src/components/settings/OAuth2Setting.jsx

@@ -18,17 +18,14 @@ For commercial licensing, please contact [email protected]
 */
 
 import React, { useEffect, useState } from 'react';
-import { Card, Spin, Space, Button } from '@douyinfe/semi-ui';
+import { Spin } from '@douyinfe/semi-ui';
 import { API, showError } from '../../helpers';
-import OAuth2ServerSettings from '../../pages/Setting/OAuth2/OAuth2ServerSettings';
-import OAuth2ClientSettings from '../../pages/Setting/OAuth2/OAuth2ClientSettings';
-// import OAuth2Tools from '../../pages/Setting/OAuth2/OAuth2Tools';
-import OAuth2ToolsModal from '../../components/modals/oauth2/OAuth2ToolsModal';
-import OAuth2QuickStartModal from '../../components/modals/oauth2/OAuth2QuickStartModal';
-import JWKSManagerModal from '../../components/modals/oauth2/JWKSManagerModal';
+import { useTranslation } from 'react-i18next';
+import OAuth2ServerSettings from './oauth2/OAuth2ServerSettings';
+import OAuth2ClientSettings from './oauth2/OAuth2ClientSettings';
 
 const OAuth2Setting = () => {
-  // 原样保存后端 Option 键值(字符串),避免类型转换造成子组件解析错误
+  const { t } = useTranslation();
   const [options, setOptions] = useState({});
   const [loading, setLoading] = useState(false);
 
@@ -47,7 +44,7 @@ const OAuth2Setting = () => {
         showError(message);
       }
     } catch (error) {
-      showError('获取OAuth2设置失败');
+      showError(t('获取OAuth2设置失败'));
     } finally {
       setLoading(false);
     }
@@ -61,33 +58,17 @@ const OAuth2Setting = () => {
     getOptions();
   }, []);
 
-  const [qsVisible, setQsVisible] = useState(false);
-  const [jwksVisible, setJwksVisible] = useState(false);
-  const [toolsVisible, setToolsVisible] = useState(false);
-
   return (
-    <div
-      style={{
-        display: 'flex',
-        flexDirection: 'column',
-        gap: '10px',
-        marginTop: '10px',
-      }}
-    >
-      <Card>
-        <Space>
-          <Button type='primary' onClick={()=>setQsVisible(true)}>一键初始化向导</Button>
-          <Button onClick={()=>setJwksVisible(true)}>JWKS 管理</Button>
-          <Button onClick={()=>setToolsVisible(true)}>调试助手</Button>
-          <Button onClick={()=>window.open('/oauth-demo.html','_blank')}>前端 Demo</Button>
-        </Space>
-      </Card>
-      <OAuth2QuickStartModal visible={qsVisible} onClose={()=>setQsVisible(false)} onDone={refresh} />
-      <JWKSManagerModal visible={jwksVisible} onClose={()=>setJwksVisible(false)} />
-      <OAuth2ToolsModal visible={toolsVisible} onClose={()=>setToolsVisible(false)} />
-      <OAuth2ServerSettings options={options} refresh={refresh} onOpenJWKS={()=>setJwksVisible(true)} />
+    <Spin spinning={loading} size='large'>
+      {/* 服务器配置 */}
+      <OAuth2ServerSettings 
+        options={options} 
+        refresh={refresh}
+      />
+
+      {/* 客户端管理 */}
       <OAuth2ClientSettings />
-    </div>
+    </Spin>
   );
 };
 

+ 504 - 0
web/src/components/settings/oauth2/OAuth2ClientSettings.jsx

@@ -0,0 +1,504 @@
+/*
+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, { useEffect, useState } from 'react';
+import { 
+  Card, 
+  Table, 
+  Button, 
+  Space, 
+  Tag, 
+  Typography, 
+  Input, 
+  Popconfirm,
+  Modal,
+  Banner,
+  Row,
+  Col,
+  Empty,
+  Tooltip
+} from '@douyinfe/semi-ui';
+import { 
+  Search, 
+  Plus, 
+  RefreshCw,
+  Edit,
+  Key,
+  Trash2,
+  Eye,
+  User,
+  Grid3X3
+} from 'lucide-react';
+import { API, showError, showSuccess } from '../../../helpers';
+import CreateOAuth2ClientModal from './modals/CreateOAuth2ClientModal';
+import EditOAuth2ClientModal from './modals/EditOAuth2ClientModal';
+import { useTranslation } from 'react-i18next';
+
+const { Text, Title } = Typography;
+
+export default function OAuth2ClientSettings() {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [clients, setClients] = useState([]);
+  const [filteredClients, setFilteredClients] = useState([]);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [showCreateModal, setShowCreateModal] = useState(false);
+  const [showEditModal, setShowEditModal] = useState(false);
+  const [editingClient, setEditingClient] = useState(null);
+  const [showSecretModal, setShowSecretModal] = useState(false);
+  const [currentSecret, setCurrentSecret] = useState('');
+
+  // 加载客户端列表
+  const loadClients = async () => {
+    setLoading(true);
+    try {
+      const res = await API.get('/api/oauth_clients/');
+      if (res.data.success) {
+        setClients(res.data.data || []);
+        setFilteredClients(res.data.data || []);
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(t('加载OAuth2客户端失败'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 搜索过滤
+  const handleSearch = (value) => {
+    setSearchKeyword(value);
+    if (!value) {
+      setFilteredClients(clients);
+    } else {
+      const filtered = clients.filter(client =>
+        client.name?.toLowerCase().includes(value.toLowerCase()) ||
+        client.id?.toLowerCase().includes(value.toLowerCase()) ||
+        client.description?.toLowerCase().includes(value.toLowerCase())
+      );
+      setFilteredClients(filtered);
+    }
+  };
+
+  // 删除客户端
+  const handleDelete = async (client) => {
+    try {
+      const res = await API.delete(`/api/oauth_clients/${client.id}`);
+      if (res.data.success) {
+        showSuccess(t('删除成功'));
+        loadClients();
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(t('删除失败'));
+    }
+  };
+
+  // 重新生成密钥
+  const handleRegenerateSecret = async (client) => {
+    try {
+      const res = await API.post(`/api/oauth_clients/${client.id}/regenerate_secret`);
+      if (res.data.success) {
+        setCurrentSecret(res.data.client_secret);
+        setShowSecretModal(true);
+        loadClients();
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(t('重新生成密钥失败'));
+    }
+  };
+
+  // 快速查看服务器信息
+  const showServerInfo = async () => {
+    try {
+      const res = await API.get('/api/oauth/server-info');
+      Modal.info({
+        title: t('OAuth2 服务器信息'),
+        content: (
+          <div>
+            <Text>{t('授权服务器配置')}:</Text>
+            <pre style={{ 
+              background: '#f8f9fa', 
+              padding: '12px', 
+              borderRadius: '4px',
+              marginTop: '8px',
+              fontSize: '12px',
+              maxHeight: '300px',
+              overflow: 'auto'
+            }}>
+              {JSON.stringify(res.data, null, 2)}
+            </pre>
+          </div>
+        ),
+        width: 600
+      });
+    } catch (error) {
+      showError(t('获取服务器信息失败'));
+    }
+  };
+
+  // 查看JWKS
+  const showJWKS = async () => {
+    try {
+      const res = await API.get('/api/oauth/jwks');
+      Modal.info({
+        title: t('JWKS 信息'),
+        content: (
+          <div>
+            <Text>{t('JSON Web Key Set')}:</Text>
+            <pre style={{ 
+              background: '#f8f9fa', 
+              padding: '12px', 
+              borderRadius: '4px',
+              marginTop: '8px',
+              fontSize: '12px',
+              maxHeight: '300px',
+              overflow: 'auto'
+            }}>
+              {JSON.stringify(res.data, null, 2)}
+            </pre>
+          </div>
+        ),
+        width: 600
+      });
+    } catch (error) {
+      showError(t('获取JWKS失败'));
+    }
+  };
+
+  // 表格列定义
+  const columns = [
+    {
+      title: t('客户端信息'),
+      key: 'info',
+      render: (_, record) => (
+        <div>
+          <div style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
+            <User size={16} style={{ marginRight: 6, color: 'var(--semi-color-text-2)' }} />
+            <Text strong>{record.name}</Text>
+          </div>
+          <div style={{ display: 'flex', alignItems: 'center' }}>
+            <Grid3X3 size={16} style={{ marginRight: 6, color: 'var(--semi-color-text-2)' }} />
+            <Text type="tertiary" size="small" code copyable>
+              {record.id}
+            </Text>
+          </div>
+        </div>
+      ),
+      width: 200,
+    },
+    {
+      title: t('类型'),
+      dataIndex: 'client_type',
+      key: 'client_type',
+      render: (text) => (
+        <Tag 
+          color={text === 'confidential' ? 'blue' : 'green'}
+          style={{ borderRadius: '12px' }}
+        >
+          {text === 'confidential' ? t('机密客户端') : t('公开客户端')}
+        </Tag>
+      ),
+      width: 120,
+    },
+    {
+      title: t('授权类型'),
+      dataIndex: 'grant_types',
+      key: 'grant_types',
+      render: (grantTypes) => {
+        const types = typeof grantTypes === 'string' ? grantTypes.split(',') : (grantTypes || []);
+        const typeMap = {
+          'client_credentials': t('客户端凭证'),
+          'authorization_code': t('授权码'),
+          'refresh_token': t('刷新令牌')
+        };
+        return (
+          <div>
+            {types.slice(0, 2).map(type => (
+              <Tag key={type} size="small" style={{ margin: '1px', borderRadius: '8px' }}>
+                {typeMap[type] || type}
+              </Tag>
+            ))}
+            {types.length > 2 && (
+              <Tooltip content={types.slice(2).map(t => typeMap[t] || t).join(', ')}>
+                <Tag size="small" style={{ margin: '1px', borderRadius: '8px' }}>
+                  +{types.length - 2}
+                </Tag>
+              </Tooltip>
+            )}
+          </div>
+        );
+      },
+      width: 150,
+    },
+    {
+      title: t('状态'),
+      dataIndex: 'status',
+      key: 'status',
+      render: (status) => (
+        <Tag 
+          color={status === 1 ? 'green' : 'red'}
+          style={{ borderRadius: '12px' }}
+        >
+          {status === 1 ? t('启用') : t('禁用')}
+        </Tag>
+      ),
+      width: 80,
+    },
+    {
+      title: t('创建时间'),
+      dataIndex: 'created_time',
+      key: 'created_time',
+      render: (time) => new Date(time * 1000).toLocaleString(),
+      width: 150,
+    },
+    {
+      title: t('操作'),
+      key: 'action',
+      render: (_, record) => (
+        <Space size="small">
+          <Tooltip content={t('编辑客户端')}>
+            <Button
+              theme="borderless"
+              type="primary"
+              size="small"
+              icon={<Edit size={14} />}
+              onClick={() => {
+                setEditingClient(record);
+                setShowEditModal(true);
+              }}
+            />
+          </Tooltip>
+          {record.client_type === 'confidential' && (
+            <Popconfirm
+              title={t('确认重新生成客户端密钥?')}
+              content={
+                <div>
+                  <div>{t('客户端')}:{record.name}</div>
+                  <div style={{ marginTop: 6, color: 'var(--semi-color-warning)' }}>
+                    ⚠️ {t('操作不可撤销,旧密钥将立即失效。')}
+                  </div>
+                </div>
+              }
+              onConfirm={() => handleRegenerateSecret(record)}
+              okText={t('确认')}
+              cancelText={t('取消')}
+            >
+              <Tooltip content={t('重新生成密钥')}>
+                <Button
+                  theme="borderless"
+                  type="secondary"
+                  size="small"
+                  icon={<Key size={14} />}
+                />
+              </Tooltip>
+            </Popconfirm>
+          )}
+          <Popconfirm
+            title={t('请再次确认删除该客户端')}
+            content={
+              <div>
+                <div>{t('客户端')}:{record.name}</div>
+                <div style={{ marginTop: 6, color: 'var(--semi-color-danger)' }}>
+                  🗑️ {t('删除后无法恢复,相关 API 调用将立即失效。')}
+                </div>
+              </div>
+            }
+            onConfirm={() => handleDelete(record)}
+            okText={t('确定删除')}
+            cancelText={t('取消')}
+          >
+            <Tooltip content={t('删除客户端')}>
+              <Button
+                theme="borderless"
+                type="danger"
+                size="small"
+                icon={<Trash2 size={14} />}
+              />
+            </Tooltip>
+          </Popconfirm>
+        </Space>
+      ),
+      width: 120,
+      fixed: 'right',
+    },
+  ];
+
+  useEffect(() => {
+    loadClients();
+  }, []);
+
+  return (
+    <Card 
+      className='!rounded-2xl shadow-sm border-0'
+      style={{ marginTop: 10 }}
+      title={
+        <div className='flex items-center'>
+          <User size={18} className='mr-2' />
+          <Text strong>{t('OAuth2 客户端管理')}</Text>
+        </div>
+      }
+    >
+      <div style={{ marginBottom: 16 }}>
+        <Text type="tertiary">
+          {t('管理OAuth2客户端应用程序,每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用,公开客户端用于移动应用或单页应用。')}
+        </Text>
+      </div>
+      
+      {/* 工具栏 */}
+      <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
+        <Col xs={24} sm={24} md={10} lg={8}>
+          <Input
+            prefix={<Search size={16} />}
+            placeholder={t('搜索客户端名称、ID或描述')}
+            value={searchKeyword}
+            onChange={handleSearch}
+            showClear
+            style={{ width: '100%' }}
+          />
+        </Col>
+        <Col xs={24} sm={24} md={14} lg={16}>
+          <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, flexWrap: 'wrap' }}>
+            <Button 
+              icon={<RefreshCw size={16} />} 
+              onClick={loadClients}
+              size="default"
+            >
+              <span className="hidden sm:inline">{t('刷新')}</span>
+            </Button>
+            <Button 
+              icon={<Eye size={16} />} 
+              onClick={showServerInfo}
+              size="default"
+            >
+              <span className="hidden sm:inline">{t('服务器信息')}</span>
+            </Button>
+            <Button 
+              icon={<Key size={16} />} 
+              onClick={showJWKS}
+              size="default"
+            >
+              <span className="hidden md:inline">{t('查看JWKS')}</span>
+            </Button>
+            <Button
+              type="primary"
+              icon={<Plus size={16} />}
+              onClick={() => setShowCreateModal(true)}
+              size="default"
+            >
+              {t('创建客户端')}
+            </Button>
+          </div>
+        </Col>
+      </Row>
+
+      {/* 客户端表格 */}
+      <Table
+        columns={columns}
+        dataSource={filteredClients}
+        rowKey="id"
+        loading={loading}
+        pagination={{
+          showSizeChanger: true,
+          showQuickJumper: true,
+          showTotal: (total, range) => t('第 {{start}}-{{end}} 条,共 {{total}} 条', { start: range[0], end: range[1], total }),
+          pageSize: 10,
+          size: 'small'
+        }}
+        scroll={{ x: 800 }}
+        empty={
+          <Empty
+            image={<User size={48} />}
+            title={t('暂无OAuth2客户端')}
+            description={t('还没有创建任何客户端,点击下方按钮创建第一个客户端')}
+          >
+            <Button
+              type="primary"
+              icon={<Plus size={16} />}
+              onClick={() => setShowCreateModal(true)}
+              style={{ marginTop: 16 }}
+            >
+              {t('创建第一个客户端')}
+            </Button>
+          </Empty>
+        }
+      />
+
+      {/* 创建客户端模态框 */}
+      <CreateOAuth2ClientModal
+        visible={showCreateModal}
+        onCancel={() => setShowCreateModal(false)}
+        onSuccess={() => {
+          setShowCreateModal(false);
+          loadClients();
+        }}
+      />
+
+      {/* 编辑客户端模态框 */}
+      <EditOAuth2ClientModal
+        visible={showEditModal}
+        client={editingClient}
+        onCancel={() => {
+          setShowEditModal(false);
+          setEditingClient(null);
+        }}
+        onSuccess={() => {
+          setShowEditModal(false);
+          setEditingClient(null);
+          loadClients();
+        }}
+      />
+
+      {/* 密钥显示模态框 */}
+      <Modal
+        title={t('客户端密钥已重新生成')}
+        visible={showSecretModal}
+        onCancel={() => setShowSecretModal(false)}
+        onOk={() => setShowSecretModal(false)}
+        cancelText=""
+        okText={t('我已复制保存')}
+        width={600}
+      >
+        <div>
+          <Banner
+            type="warning"
+            description={t('新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。')}
+            style={{ marginBottom: 16 }}
+          />
+          <div style={{ 
+            background: '#f8f9fa', 
+            padding: '16px', 
+            borderRadius: '6px',
+            fontFamily: 'monospace',
+            wordBreak: 'break-all',
+            border: '1px solid var(--semi-color-border)'
+          }}>
+            <Text code copyable style={{ fontSize: '14px' }}>
+              {currentSecret}
+            </Text>
+          </div>
+        </div>
+      </Modal>
+    </Card>
+  );
+}

+ 536 - 0
web/src/components/settings/oauth2/OAuth2ServerSettings.jsx

@@ -0,0 +1,536 @@
+/*
+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, { useEffect, useState, useRef } from 'react';
+import {
+  Banner,
+  Button,
+  Col,
+  Form,
+  Row,
+  Card,
+  Typography,
+  Space,
+  Tag
+} from '@douyinfe/semi-ui';
+import {
+  Server,
+  Key,
+  Shield,
+  Settings,
+  CheckCircle,
+  AlertTriangle,
+  PlayCircle,
+  Wrench,
+  BookOpen
+} from 'lucide-react';
+import OAuth2ToolsModal from './modals/OAuth2ToolsModal';
+import OAuth2QuickStartModal from './modals/OAuth2QuickStartModal';
+import JWKSManagerModal from './modals/JWKSManagerModal';
+import {
+  compareObjects,
+  API,
+  showError,
+  showSuccess,
+  showWarning,
+} from '../../../helpers';
+import { useTranslation } from 'react-i18next';
+
+const { Title, 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 [qsVisible, setQsVisible] = useState(false);
+  const [jwksVisible, setJwksVisible] = useState(false);
+  const [toolsVisible, setToolsVisible] = 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 () => {
+    try {
+      const res = await API.get('/api/oauth/server-info');
+      if (res.status === 200 && (res.data.issuer || res.data.authorization_endpoint)) {
+        showSuccess('OAuth2服务器运行正常');
+        setServerInfo(res.data);
+      } else {
+        showError('OAuth2服务器测试失败');
+      }
+    } catch (error) {
+      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(() => {
+    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();
+    }
+  }, [inputs['oauth2.enabled']]);
+
+  const isEnabled = inputs['oauth2.enabled'];
+
+  return (
+    <div>
+      {/* OAuth2 & SSO 管理 */}
+      <Card
+        className='!rounded-2xl shadow-sm border-0'
+        style={{ marginTop: 10 }}
+        title={
+          <div className='flex items-center'>
+            <Server size={18} className='mr-2' />
+            <Text strong>{t('OAuth2 & SSO 管理')}</Text>
+          </div>
+        }
+      >
+        <div style={{ marginBottom: 16 }}>
+          <Text type="tertiary">
+            {t('OAuth2 是一个开放标准的授权框架,允许用户授权第三方应用访问他们的资源,而无需分享他们的凭据。支持标准的 API 认证与授权流程。')}
+          </Text>
+        </div>
+
+        {!isEnabled && (
+          <Banner
+            type="info"
+            icon={<Settings size={16} />}
+            description={t('OAuth2 功能尚未启用,建议使用一键初始化向导完成基础配置。')}
+            style={{ marginBottom: 16 }}
+          />
+        )}
+
+        {/* 快捷操作按钮 */}
+        <Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
+          <Col xs={12} sm={6} md={6} lg={6}>
+            <Button
+              type="primary"
+              icon={<PlayCircle size={16} />}
+              onClick={() => setQsVisible(true)}
+              style={{ width: '100%' }}
+            >
+              <span className="hidden sm:inline">{t('一键初始化')}</span>
+            </Button>
+          </Col>
+          <Col xs={12} sm={6} md={6} lg={6}>
+            <Button
+              icon={<Key size={16} />}
+              onClick={() => setJwksVisible(true)}
+              style={{ width: '100%' }}
+            >
+              <span className="hidden sm:inline">{t('密钥管理')}</span>
+            </Button>
+          </Col>
+          <Col xs={12} sm={6} md={6} lg={6}>
+            <Button
+              icon={<Wrench size={16} />}
+              onClick={() => setToolsVisible(true)}
+              style={{ width: '100%' }}
+            >
+              <span className="hidden sm:inline">{t('调试助手')}</span>
+            </Button>
+          </Col>
+          <Col xs={12} sm={6} md={6} lg={6}>
+            <Button
+              icon={<BookOpen size={16} />}
+              onClick={() => window.open('/oauth-demo.html', '_blank')}
+              style={{ width: '100%' }}
+            >
+              <span className="hidden sm:inline">{t('前端演示')}</span>
+            </Button>
+          </Col>
+        </Row>
+
+        <Form
+          initValues={inputs}
+          getFormApi={(formAPI) => (refForm.current = formAPI)}
+        >
+          {!keysReady && isEnabled && (
+            <Banner
+              type='warning'
+              icon={<AlertTriangle size={16} />}
+              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={
+                  <span style={{ display: 'flex', alignItems: 'center' }}>
+                    <Shield size={16} style={{ marginRight: 4 }} />
+                    {t('启用 OAuth2 & SSO')}
+                  </span>
+                }
+                checkedText={t('开')}
+                uncheckedText={t('关')}
+                value={inputs['oauth2.enabled']}
+                onChange={handleFieldChange('oauth2.enabled')}
+                extraText={t("开启后将允许以 OAuth2/OIDC 标准进行授权与登录")}
+                size="large"
+              />
+            </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>
+
+          {/* 服务器状态 */}
+          {isEnabled && serverInfo && (
+            <div style={{
+              marginTop: 16,
+              padding: '12px 16px',
+              backgroundColor: 'var(--semi-color-success-light-default)',
+              borderRadius: '8px',
+              border: '1px solid var(--semi-color-success-light-active)'
+            }}>
+              <div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
+                <CheckCircle size={16} style={{ marginRight: 6, color: 'var(--semi-color-success)' }} />
+                <Text strong style={{ color: 'var(--semi-color-success)' }}>{t('服务器运行正常')}</Text>
+              </div>
+              <Space wrap>
+                <Tag color="green">{t('发行人')}: {serverInfo.issuer}</Tag>
+                {serverInfo.authorization_endpoint && <Tag>{t('授权端点')}: {t('已配置')}</Tag>}
+                {serverInfo.token_endpoint && <Tag>{t('令牌端点')}: {t('已配置')}</Tag>}
+                {serverInfo.jwks_uri && <Tag>JWKS: {t('已配置')}</Tag>}
+              </Space>
+            </div>
+          )}
+
+          <div style={{ marginTop: 16 }}>
+            <Button type="primary" onClick={onSubmit} loading={loading}>
+              {t('保存基础配置')}
+            </Button>
+            {isEnabled && (
+              <Button
+                type="secondary"
+                onClick={testOAuth2}
+                style={{ marginLeft: 8 }}
+              >
+                测试连接
+              </Button>
+            )}
+          </div>
+        </Form>
+      </Card>
+
+      {/* 高级配置 */}
+      {isEnabled && (
+        <>
+          {/* 令牌配置 */}
+          <Card
+            className='!rounded-2xl shadow-sm border-0'
+            style={{ marginTop: 10 }}
+            title={
+              <div className='flex items-center'>
+                <Key size={18} className='mr-2' />
+                <Text strong>{t('令牌配置')}</Text>
+              </div>
+            }
+            footer={
+              <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>
+            }
+          >
+
+            <Form initValues={inputs}>
+              <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%' }}
+                  />
+                </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%' }}
+                  />
+                </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%' }}
+                  />
+                </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%' }}
+                  >
+                    <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%' }}
+                  />
+                </Col>
+              </Row>
+
+              <div style={{ marginTop: 16 }}>
+                <Button type="primary" onClick={onSubmit} loading={loading}>
+                  {t('更新令牌配置')}
+                </Button>
+                <Button
+                  type='secondary'
+                  onClick={() => setJwksVisible(true)}
+                  style={{ marginLeft: 8 }}
+                >
+                  密钥管理
+                </Button>
+              </div>
+            </Form>
+          </Card>
+
+          {/* 授权配置 */}
+          <Card
+            className='!rounded-2xl shadow-sm border-0'
+            style={{ marginTop: 10 }}
+            title={
+              <div className='flex items-center'>
+                <Settings size={18} className='mr-2' />
+                <Text strong>{t('授权配置')}</Text>
+              </div>
+            }
+          >
+
+            <Form initValues={inputs}>
+              <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%' }}
+                  >
+                    <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验证')}
+                    checkedText={t('开')}
+                    uncheckedText={t('关')}
+                    value={inputs['oauth2.require_pkce']}
+                    onChange={handleFieldChange('oauth2.require_pkce')}
+                    extraText={t("为授权码流程强制启用PKCE,提高安全性")}
+                    size="large"
+                  />
+                </Col>
+              </Row>
+
+              <div style={{ marginTop: 16 }}>
+                <Button type="primary" onClick={onSubmit} loading={loading}>
+                  {t('更新授权配置')}
+                </Button>
+              </div>
+            </Form>
+          </Card>
+
+        </>
+      )}
+
+      {/* 模态框 */}
+      <OAuth2QuickStartModal
+        visible={qsVisible}
+        onClose={() => setQsVisible(false)}
+        onDone={() => { props?.refresh && props.refresh(); }}
+      />
+      <JWKSManagerModal
+        visible={jwksVisible}
+        onClose={() => setJwksVisible(false)}
+      />
+      <OAuth2ToolsModal
+        visible={toolsVisible}
+        onClose={() => setToolsVisible(false)}
+      />
+    </div>
+  );
+}

+ 171 - 135
web/src/components/modals/oauth2/CreateOAuth2ClientModal.jsx → web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx

@@ -17,27 +17,29 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact [email protected]
 */
 
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useEffect, useState } from 'react';
 import {
   Modal,
   Form,
   Input,
   Select,
-  TextArea,
   Switch,
   Space,
   Typography,
   Divider,
-  Tag,
   Button,
+  Row,
+  Col,
 } from '@douyinfe/semi-ui';
-import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
-import { API, showError, showSuccess, showInfo } from '../../../helpers';
+import { Plus, Trash2 } from 'lucide-react';
+import { API, showError, showSuccess } from '../../../../helpers';
+import { useTranslation } from 'react-i18next';
 
 const { Text, Paragraph } = Typography;
 const { Option } = Select;
 
 const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
+  const { t } = useTranslation();
   const [formApi, setFormApi] = useState(null);
   const [loading, setLoading] = useState(false);
   const [redirectUris, setRedirectUris] = useState([]);
@@ -137,27 +139,27 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
 
       // 业务校验
       if (!grantTypes.length) {
-        showError('请至少选择一种授权类型');
+        showError(t('请至少选择一种授权类型'));
         return;
       }
       // 校验是否包含不被允许的授权类型
       const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g));
       if (invalids.length) {
-        showError(`不被允许的授权类型: ${invalids.join(', ')}`);
+        showError(t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') }));
         return;
       }
       if (clientType === 'public' && grantTypes.includes('client_credentials')) {
-        showError('公开客户端不允许使用client_credentials授权类型');
+        showError(t('公开客户端不允许使用client_credentials授权类型'));
         return;
       }
       if (grantTypes.includes('authorization_code')) {
         if (!validRedirectUris.length) {
-          showError('选择授权码授权类型时,必须填写至少一个重定向URI');
+          showError(t('选择授权码授权类型时,必须填写至少一个重定向URI'));
           return;
         }
         const allValid = validRedirectUris.every(isValidRedirectUri);
         if (!allValid) {
-          showError('重定向URI格式不合法:仅支持https,或本地开发使用http');
+          showError(t('重定向URI格式不合法:仅支持https,或本地开发使用http'));
           return;
         }
       }
@@ -173,17 +175,17 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
       const { success, message, client_id, client_secret } = res.data;
       
       if (success) {
-        showSuccess('OAuth2客户端创建成功');
+        showSuccess(t('OAuth2客户端创建成功'));
         
         // 显示客户端信息
         Modal.info({
-          title: '客户端创建成功',
+          title: t('客户端创建成功'),
           content: (
             <div>
-              <Paragraph>请妥善保存以下信息:</Paragraph>
+              <Paragraph>{t('请妥善保存以下信息:')}</Paragraph>
               <div style={{ background: '#f8f9fa', padding: '16px', borderRadius: '6px' }}>
                 <div style={{ marginBottom: '12px' }}>
-                  <Text strong>客户端ID:</Text>
+                  <Text strong>{t('客户端ID')}:</Text>
                   <br />
                   <Text code copyable style={{ fontFamily: 'monospace' }}>
                     {client_id}
@@ -191,7 +193,7 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
                 </div>
                 {client_secret && (
                   <div>
-                    <Text strong>客户端密钥(仅此一次显示):</Text>
+                    <Text strong>{t('客户端密钥(仅此一次显示)')}:</Text>
                     <br />
                     <Text code copyable style={{ fontFamily: 'monospace' }}>
                       {client_secret}
@@ -201,8 +203,8 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
               </div>
               <Paragraph type="warning" style={{ marginTop: '12px' }}>
                 {client_secret 
-                  ? '客户端密钥仅显示一次,请立即复制保存。' 
-                  : '公开客户端无需密钥。'
+                  ? t('客户端密钥仅显示一次,请立即复制保存。') 
+                  : t('公开客户端无需密钥。')
                 }
               </Paragraph>
             </div>
@@ -217,7 +219,7 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
         showError(message);
       }
     } catch (error) {
-      showError('创建OAuth2客户端失败');
+      showError(t('创建OAuth2客户端失败'));
     } finally {
       setLoading(false);
     }
@@ -271,15 +273,21 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
 
   return (
     <Modal
-      title="创建OAuth2客户端"
+      title={t('创建OAuth2客户端')}
       visible={visible}
       onCancel={handleCancel}
       onOk={() => formApi?.submitForm()}
-      okText="创建"
-      cancelText="取消"
+      okText={t('创建')}
+      cancelText={t('取消')}
       confirmLoading={loading}
-      width={600}
-      style={{ top: 50 }}
+      width="90vw"
+      style={{ 
+        top: 20, 
+        maxWidth: '800px',
+        '@media (min-width: 768px)': {
+          width: '600px'
+        }
+      }}
     >
       <Form
         getFormApi={(api) => setFormApi(api)}
@@ -293,147 +301,175 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
         labelPosition="top"
       >
         {/* 基本信息 */}
-        <Form.Input
-          field="name"
-          label="客户端名称"
-          placeholder="输入客户端名称"
-          rules={[{ required: true, message: '请输入客户端名称' }]}
-        />
-
-        <Form.TextArea
-          field="description"
-          label="描述"
-          placeholder="输入客户端描述"
-          rows={3}
-        />
+        <Row gutter={[16, 24]}>
+          <Col xs={24}>
+            <Form.Input
+              field="name"
+              label={t('客户端名称')}
+              placeholder={t('输入客户端名称')}
+              rules={[{ required: true, message: t('请输入客户端名称') }]}
+              style={{ width: '100%' }}
+            />
+          </Col>
+          <Col xs={24}>
+            <Form.TextArea
+              field="description"
+              label={t('描述')}
+              placeholder={t('输入客户端描述')}
+              rows={3}
+              style={{ width: '100%' }}
+            />
+          </Col>
+        </Row>
 
         {/* 客户端类型 */}
         <div>
-          <Text strong>客户端类型</Text>
+          <Text strong>{t('客户端类型')}</Text>
           <Paragraph type="tertiary" size="small" style={{ marginTop: 4, marginBottom: 8 }}>
-            选择适合您应用程序的客户端类型。
+            {t('选择适合您应用程序的客户端类型。')}
           </Paragraph>
-          <div style={{ display: 'flex', gap: '12px', marginBottom: 16 }}>
-            <div 
-              onClick={() => setClientType('confidential')}
-              style={{
-                flex: 1,
-                padding: '12px',
-                border: `2px solid ${clientType === 'confidential' ? '#3370ff' : '#e4e6e9'}`,
-                borderRadius: '6px',
-                cursor: 'pointer',
-                background: clientType === 'confidential' ? '#f0f5ff' : '#fff'
-              }}
-            >
-              <Text strong>机密客户端(Confidential)</Text>
-              <Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
-                用于服务器端应用,可以安全地存储客户端密钥
-              </Paragraph>
-            </div>
-            <div 
-              onClick={() => setClientType('public')}
-              style={{
-                flex: 1,
-                padding: '12px',
-                border: `2px solid ${clientType === 'public' ? '#3370ff' : '#e4e6e9'}`,
-                borderRadius: '6px',
-                cursor: 'pointer',
-                background: clientType === 'public' ? '#f0f5ff' : '#fff'
-              }}
-            >
-              <Text strong>公开客户端(Public)</Text>
-              <Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
-                用于移动应用或单页应用,无法安全存储密钥
-              </Paragraph>
-            </div>
-          </div>
+          <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
+            <Col xs={24} md={12}>
+              <div 
+                onClick={() => setClientType('confidential')}
+                style={{
+                  padding: '16px',
+                  border: `2px solid ${clientType === 'confidential' ? '#3370ff' : '#e4e6e9'}`,
+                  borderRadius: '8px',
+                  cursor: 'pointer',
+                  background: clientType === 'confidential' ? '#f0f5ff' : '#fff',
+                  transition: 'all 0.2s ease',
+                  minHeight: '80px'
+                }}
+              >
+                <Text strong>{t('机密客户端(Confidential)')}</Text>
+                <Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
+                  {t('用于服务器端应用,可以安全地存储客户端密钥')}
+                </Paragraph>
+              </div>
+            </Col>
+            <Col xs={24} md={12}>
+              <div 
+                onClick={() => setClientType('public')}
+                style={{
+                  padding: '16px',
+                  border: `2px solid ${clientType === 'public' ? '#3370ff' : '#e4e6e9'}`,
+                  borderRadius: '8px',
+                  cursor: 'pointer',
+                  background: clientType === 'public' ? '#f0f5ff' : '#fff',
+                  transition: 'all 0.2s ease',
+                  minHeight: '80px'
+                }}
+              >
+                <Text strong>{t('公开客户端(Public)')}</Text>
+                <Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
+                  {t('用于移动应用或单页应用,无法安全存储密钥')}
+                </Paragraph>
+              </div>
+            </Col>
+          </Row>
         </div>
 
-        {/* 授权类型 */}
-        <Form.Select
-          field="grant_types"
-          label="允许的授权类型"
-          multiple
-          value={grantTypes}
-          onChange={handleGrantTypesChange}
-          rules={[{ required: true, message: '请选择至少一种授权类型' }]}
-        >
-          <Option value="client_credentials" disabled={isGrantTypeDisabled('client_credentials')}>
-            Client Credentials(客户端凭证)
-          </Option>
-          <Option value="authorization_code" disabled={isGrantTypeDisabled('authorization_code')}>
-            Authorization Code(授权码)
-          </Option>
-          <Option value="refresh_token" disabled={isGrantTypeDisabled('refresh_token')}>
-            Refresh Token(刷新令牌)
-          </Option>
-        </Form.Select>
+        <Row gutter={[16, 24]}>
+          {/* 授权类型 */}
+          <Col xs={24} lg={12}>
+            <Form.Select
+              field="grant_types"
+              label={t('允许的授权类型')}
+              multiple
+              value={grantTypes}
+              onChange={handleGrantTypesChange}
+              rules={[{ required: true, message: t('请选择至少一种授权类型') }]}
+              style={{ width: '100%' }}
+            >
+              <Option value="client_credentials" disabled={isGrantTypeDisabled('client_credentials')}>
+                {t('Client Credentials(客户端凭证)')}
+              </Option>
+              <Option value="authorization_code" disabled={isGrantTypeDisabled('authorization_code')}>
+                {t('Authorization Code(授权码)')}
+              </Option>
+              <Option value="refresh_token" disabled={isGrantTypeDisabled('refresh_token')}>
+                {t('Refresh Token(刷新令牌)')}
+              </Option>
+            </Form.Select>
+          </Col>
 
-        {/* Scope */}
-        <Form.Select
-          field="scopes"
-          label="允许的权限范围(Scope)"
-          multiple
-          rules={[{ required: true, message: '请选择至少一个权限范围' }]}
-        >
-          <Option value="openid">openid(OIDC 基础身份)</Option>
-          <Option value="profile">profile(用户名/昵称等)</Option>
-          <Option value="email">email(邮箱信息)</Option>
-          <Option value="api:read">api:read(读取API)</Option>
-          <Option value="api:write">api:write(写入API)</Option>
-          <Option value="admin">admin(管理员权限)</Option>
-        </Form.Select>
+          {/* Scope */}
+          <Col xs={24} lg={12}>
+            <Form.Select
+              field="scopes"
+              label={t('允许的权限范围(Scope)')}
+              multiple
+              rules={[{ required: true, message: t('请选择至少一个权限范围') }]}
+              style={{ width: '100%' }}
+            >
+              <Option value="openid">openid(OIDC 基础身份)</Option>
+              <Option value="profile">profile(用户名/昵称等)</Option>
+              <Option value="email">email(邮箱信息)</Option>
+              <Option value="api:read">api:read(读取API)</Option>
+              <Option value="api:write">api:write(写入API)</Option>
+              <Option value="admin">admin(管理员权限)</Option>
+            </Form.Select>
+          </Col>
 
-        {/* PKCE设置 */}
-        <Form.Switch
-          field="require_pkce"
-          label="强制PKCE验证"
-        />
-        <Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
-          PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。
-        </Paragraph>
+          {/* PKCE设置 */}
+          <Col xs={24}>
+            <Form.Switch
+              field="require_pkce"
+              label={t('强制PKCE验证')}
+            />
+            <Paragraph type="tertiary" size="small" style={{ marginTop: 4, marginBottom: 0 }}>
+              {t('PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。')}
+            </Paragraph>
+          </Col>
+        </Row>
 
         {/* 重定向URI */}
         {(grantTypes.includes('authorization_code') || redirectUris.length > 0) && (
           <>
-            <Divider>重定向URI配置</Divider>
+            <Divider>{t('重定向URI配置')}</Divider>
             <div style={{ marginBottom: 16 }}>
-              <Text strong>重定向URI</Text>
+              <Text strong>{t('重定向URI')}</Text>
               <Paragraph type="tertiary" size="small">
-                用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。
+                {t('用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。')}
               </Paragraph>
               
-              <Space direction="vertical" style={{ width: '100%' }}>
+              <div style={{ width: '100%' }}>
                 {redirectUris.map((uri, index) => (
-                  <Space key={index} style={{ width: '100%' }}>
-                    <Input
-                      placeholder="https://your-app.com/callback"
-                      value={uri}
-                      onChange={(value) => updateRedirectUri(index, value)}
-                      style={{ flex: 1 }}
-                    />
-                    {redirectUris.length > 1 && (
-                      <Button
-                        theme="borderless"
-                        type="danger"
-                        size="small"
-                        icon={<IconDelete />}
-                        onClick={() => removeRedirectUri(index)}
+                  <Row gutter={[8, 8]} key={index} style={{ marginBottom: 8 }}>
+                    <Col xs={redirectUris.length > 1 ? 20 : 24}>
+                      <Input
+                        placeholder="https://your-app.com/callback"
+                        value={uri}
+                        onChange={(value) => updateRedirectUri(index, value)}
+                        style={{ width: '100%' }}
                       />
+                    </Col>
+                    {redirectUris.length > 1 && (
+                      <Col xs={4} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
+                        <Button
+                          theme="borderless"
+                          type="danger"
+                          size="small"
+                          icon={<Trash2 size={14} />}
+                          onClick={() => removeRedirectUri(index)}
+                          style={{ width: '100%' }}
+                        />
+                      </Col>
                     )}
-                  </Space>
+                  </Row>
                 ))}
-              </Space>
+              </div>
               
               <Button
                 theme="borderless"
                 type="primary"
                 size="small"
-                icon={<IconPlus />}
+                icon={<Plus size={14} />}
                 onClick={addRedirectUri}
                 style={{ marginTop: 8 }}
               >
-                添加重定向URI
+                {t('添加重定向URI')}
               </Button>
             </div>
           </>

+ 4 - 4
web/src/components/modals/oauth2/EditOAuth2ClientModal.jsx → web/src/components/settings/oauth2/modals/EditOAuth2ClientModal.jsx

@@ -30,8 +30,8 @@ import {
   Divider,
   Button,
 } from '@douyinfe/semi-ui';
-import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
-import { API, showError, showSuccess } from '../../../helpers';
+import { Plus, Trash2 } from 'lucide-react';
+import { API, showError, showSuccess } from '../../../../helpers';
 
 const { Text, Paragraph } = Typography;
 const { Option } = Select;
@@ -388,7 +388,7 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
                         theme="borderless"
                         type="danger"
                         size="small"
-                        icon={<IconDelete />}
+                        icon={<Trash2 size={14} />}
                         onClick={() => removeRedirectUri(index)}
                       />
                     )}
@@ -400,7 +400,7 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
                 theme="borderless"
                 type="primary"
                 size="small"
-                icon={<IconPlus />}
+                icon={<Plus size={14} />}
                 onClick={addRedirectUri}
                 style={{ marginTop: 8 }}
               >

+ 5 - 5
web/src/components/modals/oauth2/JWKSManagerModal.jsx → web/src/components/settings/oauth2/modals/JWKSManagerModal.jsx

@@ -1,7 +1,7 @@
 import React, { useEffect, useState } from 'react';
 import { Modal, Table, Button, Space, Tag, Typography, Popconfirm, Toast, Form, TextArea, Divider, Input } from '@douyinfe/semi-ui';
-import { IconRefresh, IconDelete, IconPlay } from '@douyinfe/semi-icons';
-import { API, showError, showSuccess } from '../../../helpers';
+import { RefreshCw, Trash2, PlayCircle } from 'lucide-react';
+import { API, showError, showSuccess } from '../../../../helpers';
 
 const { Text } = Typography;
 
@@ -93,7 +93,7 @@ export default function JWKSManagerModal({ visible, onClose }) {
         <Space>
           {!r.current && (
             <Popconfirm title={`确定删除密钥 ${r.kid} ?`} content='删除后使用该 kid 签发的旧令牌仍可被验证(外部 JWKS 缓存可能仍保留)' okText='删除' onConfirm={() => del(r.kid)}>
-              <Button icon={<IconDelete />} size='small' theme='borderless'>删除</Button>
+              <Button icon={<Trash2 size={14} />} size='small' theme='borderless'>删除</Button>
             </Popconfirm>
           )}
         </Space>
@@ -110,8 +110,8 @@ export default function JWKSManagerModal({ visible, onClose }) {
       style={{ top: 48 }}
     >
       <Space style={{ marginBottom: 8 }}>
-        <Button icon={<IconRefresh />} onClick={load} loading={loading}>刷新</Button>
-        <Button icon={<IconPlay />} type='primary' onClick={rotate} loading={loading}>轮换密钥</Button>
+        <Button icon={<RefreshCw size={16} />} onClick={load} loading={loading}>刷新</Button>
+        <Button icon={<PlayCircle size={16} />} type='primary' onClick={rotate} loading={loading}>轮换密钥</Button>
         <Button onClick={()=>setShowImport(!showImport)}>导入 PEM 私钥</Button>
         <Button onClick={()=>setShowGenerate(!showGenerate)}>生成 PEM 文件</Button>
         <Button onClick={onClose}>关闭</Button>

+ 1 - 1
web/src/components/modals/oauth2/OAuth2QuickStartModal.jsx → web/src/components/settings/oauth2/modals/OAuth2QuickStartModal.jsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useMemo, useState } from 'react';
 import { Modal, Steps, Form, Input, Select, Switch, Typography, Space, Button, Tag, Toast } from '@douyinfe/semi-ui';
-import { API, showError, showSuccess } from '../../../helpers';
+import { API, showError, showSuccess } from '../../../../helpers';
 
 const { Text } = Typography;
 

+ 2 - 2
web/src/components/modals/oauth2/OAuth2ToolsModal.jsx → web/src/components/settings/oauth2/modals/OAuth2ToolsModal.jsx

@@ -1,6 +1,6 @@
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useEffect, useState } from 'react';
 import { Modal, Form, Input, Button, Space, Select, Typography, Divider, Toast, TextArea } from '@douyinfe/semi-ui';
-import { API } from '../../../helpers';
+import { API } from '../../../../helpers';
 
 const { Text } = Typography;
 

+ 240 - 122
web/src/pages/OAuth/Consent.jsx

@@ -1,17 +1,63 @@
 import React, { useEffect, useMemo, useState } from 'react';
-import { Card, Button, Typography, Tag, Space, Divider, Spin, Banner, Descriptions, Avatar, Tooltip } from '@douyinfe/semi-ui';
-import { IconShield, IconTickCircle, IconClose } from '@douyinfe/semi-icons';
+import { Card, Button, Typography, Spin, Banner, Avatar, Divider, Popover } from '@douyinfe/semi-ui';
+import { Link, Dot, Key, User, Mail, Eye, Pencil, Shield } from 'lucide-react';
 import { useLocation } from 'react-router-dom';
-import { API, showError } from '../../helpers';
+import { useTranslation } from 'react-i18next';
+import { API, getLogo } from '../../helpers';
+import { stringToColor } from '../../helpers/render';
 
-const { Title, Text, Paragraph } = Typography;
+const { Title, Text } = Typography;
 
 function useQuery() {
   const { search } = useLocation();
   return useMemo(() => new URLSearchParams(search), [search]);
 }
 
+// 获取scope对应的图标
+function getScopeIcon(scopeName) {
+  switch (scopeName) {
+    case 'openid':
+      return Key;
+    case 'profile':
+      return User;
+    case 'email':
+      return Mail;
+    case 'api:read':
+      return Eye;
+    case 'api:write':
+      return Pencil;
+    case 'admin':
+      return Shield;
+    default:
+      return Dot;
+  }
+}
+
+// 权限项组件
+function ScopeItem({ name, description }) {
+  const Icon = getScopeIcon(name);
+
+  return (
+    <div className='flex items-start gap-3 py-2'>
+      <div className='w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5'>
+        <Icon size={24} />
+      </div>
+      <div className='flex-1 min-w-0'>
+        <Text strong className='block'>
+          {name}
+        </Text>
+        {description && (
+          <Text type='tertiary' size='small' className='block mt-1'>
+            {description}
+          </Text>
+        )}
+      </div>
+    </div>
+  );
+}
+
 export default function OAuthConsent() {
+  const { t } = useTranslation();
   const query = useQuery();
   const [loading, setLoading] = useState(true);
   const [info, setInfo] = useState(null);
@@ -57,143 +103,215 @@ export default function OAuthConsent() {
     })();
   }, [params]);
 
-  const onApprove = () => {
-    const u = new URL(window.location.origin + '/api/oauth/authorize');
-    Object.entries(params).forEach(([k, v]) => u.searchParams.set(k, v));
-    u.searchParams.set('approve', '1');
-    window.location.href = u.toString();
-  };
-  const onDeny = () => {
+  const handleAction = (action) => {
     const u = new URL(window.location.origin + '/api/oauth/authorize');
     Object.entries(params).forEach(([k, v]) => u.searchParams.set(k, v));
-    u.searchParams.set('deny', '1');
+    u.searchParams.set(action, '1');
     window.location.href = u.toString();
   };
 
-  const renderScope = () => {
-    if (!info?.scope_info?.length) return (
-      <div style={{ marginTop: 6 }}>
-        {info?.scope_list?.map((s) => (
-          <Tag key={s} style={{ marginRight: 6, marginBottom: 6 }}>{s}</Tag>
-        ))}
-      </div>
-    );
-    return (
-      <div style={{ marginTop: 6 }}>
-        {info.scope_info.map((s) => (
-          <Tag key={s.Name} style={{ marginRight: 6, marginBottom: 6 }}>
-            <Tooltip content={s.Description || s.Name}>{s.Name}</Tooltip>
-          </Tag>
-        ))}
-      </div>
-    );
-  };
-
-  const displayClient = () => (
-    <div>
-      <Space align='center' style={{ marginBottom: 6 }}>
-        <Avatar size='small' style={{ backgroundColor: 'var(--semi-color-tertiary)' }}>
-          {String(info?.client?.name || info?.client?.id || 'A').slice(0, 1).toUpperCase()}
-        </Avatar>
-        <Title heading={5} style={{ margin: 0 }}>{info?.client?.name || info?.client?.id}</Title>
-        {info?.verified && <Tag type='solid' color='green'>已验证</Tag>}
-        {info?.client?.type === 'public' && <Tag>公开客户端</Tag>}
-        {info?.client?.type === 'confidential' && <Tag color='blue'>机密客户端</Tag>}
-      </Space>
-      {info?.client?.desc && (
-        <Paragraph type='tertiary' style={{ marginTop: 0 }}>{info.client.desc}</Paragraph>
-      )}
-      <Descriptions size='small' style={{ marginTop: 8 }} data={[{
-        key: '回调域名', value: info?.redirect_host || '-',
-      }, {
-        key: '申请方域', value: info?.client?.domain || '-',
-      }, {
-        key: '需要PKCE', value: info?.require_pkce ? '是' : '否',
-      }]} />
-    </div>
-  );
-
-  const displayUser = () => (
-    <Space style={{ marginTop: 8 }}>
-      <Avatar size='small'>{String(info?.user?.name || 'U').slice(0,1).toUpperCase()}</Avatar>
-      <Text>{info?.user?.name || '当前用户'}</Text>
-      {info?.user?.email && <Text type='tertiary'>({info.user.email})</Text>}
-      <Button size='small' theme='borderless' onClick={() => {
-        const u = new URL(window.location.origin + '/login');
-        u.searchParams.set('next', '/oauth/consent' + window.location.search);
-        window.location.href = u.toString();
-      }}>切换账户</Button>
-    </Space>
-  );
-
   return (
-    <div style={{ maxWidth: 840, margin: '24px auto 48px', padding: '0 16px' }}>
-      <Card style={{ borderRadius: 10 }}>
-        <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
-          <IconShield size='extra-large' />
-          <div>
-            <Title heading={4} style={{ margin: 0 }}>应用请求访问你的账户</Title>
-            <Paragraph type='tertiary' style={{ margin: 0 }}>请确认是否授权下列权限给第三方应用。</Paragraph>
-          </div>
-        </div>
-
+    <div className='min-h-screen flex items-center justify-center px-4'>
+      <div className='w-full max-w-lg'>
         {loading ? (
-          <div style={{ textAlign: 'center', padding: '24px 0' }}>
-            <Spin />
-          </div>
+          <Card className='text-center py-8'>
+            <Spin size='large' />
+            <Text type='tertiary' className='block mt-4'>{t('加载授权信息中...')}</Text>
+          </Card>
         ) : error ? (
-          <Banner type='warning' description={error === 'login_required' ? '请先登录后再继续授权。' : '暂时无法加载授权信息'} />
+          <Card>
+            <Banner
+              type='warning'
+              description={error === 'login_required' ? t('请先登录后再继续授权。') : t('暂时无法加载授权信息')}
+            />
+          </Card>
         ) : (
           info && (
-            <div>
-              <Divider margin='12px' />
-              <div style={{ display: 'grid', gridTemplateColumns: '1.3fr 0.7fr', gap: 16 }}>
-                <div>
-                  {displayClient()}
-                  {displayUser()}
-                  <div style={{ marginTop: 16 }}>
-                    <Text type='tertiary'>请求的权限范围</Text>
-                    {renderScope()}
+            <>
+              <Card
+                className='!rounded-2xl border-0'
+                footer={
+                  <div className='space-y-3'>
+                    <div className='flex gap-2'>
+                      <Button
+                        theme='outline'
+                        onClick={() => handleAction('deny')}
+                        className='w-full'
+                      >
+                        {t('取消')}
+                      </Button>
+                      <Button
+                        type='primary'
+                        theme='solid'
+                        onClick={() => handleAction('approve')}
+                        className='w-full'
+                      >
+                        {t('授权')} {info?.user?.name || t('用户')}
+                      </Button>
+                    </div>
+                    <div className='text-center'>
+                      <Text type='tertiary' size='small' className='block'>
+                        {t('授权后将重定向到')}
+                      </Text>
+                      <Text type='tertiary' size='small' className='block'>
+                        {info?.redirect_uri?.length > 60 ? info.redirect_uri.slice(0, 60) + '...' : info?.redirect_uri}
+                      </Text>
+                    </div>
                   </div>
-                  <div style={{ marginTop: 16 }}>
-                    <Text type='tertiary'>回调地址</Text>
-                    <Paragraph copyable style={{ marginTop: 4 }}>{info?.redirect_uri}</Paragraph>
+                }
+              >
+                {/* 头部:应用 → 链接 → 站点Logo */}
+                <div className='text-center py-8'>
+                  <div className='flex items-center justify-center gap-6 mb-6'>
+                    {/* 应用图标 */}
+                    <Popover
+                      content={
+                        <div className='max-w-xs p-2'>
+                          <Text strong className='block text-sm mb-1'>
+                            {info?.client?.name || info?.client?.id}
+                          </Text>
+                          {info?.client?.desc && (
+                            <Text type='tertiary' size='small' className='block'>
+                              {info.client.desc}
+                            </Text>
+                          )}
+                          {info?.client?.domain && (
+                            <Text type='tertiary' size='small' className='block mt-1'>
+                              {t('域名')}: {info.client.domain}
+                            </Text>
+                          )}
+                        </div>
+                      }
+                      trigger='hover'
+                      position='top'
+                    >
+                      <Avatar
+                        size={36}
+                        style={{
+                          backgroundColor: stringToColor(info?.client?.name || info?.client?.id || 'A'),
+                          cursor: 'pointer'
+                        }}
+                      >
+                        {String(info?.client?.name || info?.client?.id || 'A').slice(0, 1).toUpperCase()}
+                      </Avatar>
+                    </Popover>
+                    {/* 链接图标 */}
+                    <div className='w-10 h-10 rounded-full flex items-center justify-center'>
+                      <Link size={16} />
+                    </div>
+                    {/* 站点Logo */}
+                    <div className='w-12 h-12 rounded-full overflow-hidden flex items-center justify-center'>
+                      <img
+                        src={getLogo()}
+                        alt='Site Logo'
+                        className='w-full h-full object-cover'
+                        onError={(e) => {
+                          e.target.style.display = 'none';
+                          e.target.nextSibling.style.display = 'flex';
+                        }}
+                      />
+                      <div
+                        className='w-full h-full rounded-full flex items-center justify-center'
+                        style={{
+                          backgroundColor: stringToColor(window.location.hostname || 'S'),
+                          display: 'none'
+                        }}
+                      >
+                        <Text className='font-bold text-lg'>
+                          {window.location.hostname.charAt(0).toUpperCase()}
+                        </Text>
+                      </div>
+                    </div>
                   </div>
+                  <Title heading={4}>
+                    {t('授权')} {info?.client?.name || info?.client?.id}
+                  </Title>
                 </div>
-                <div>
-                  <div style={{ background: 'var(--semi-color-fill-0)', border: '1px solid var(--semi-color-border)', borderRadius: 8, padding: 12 }}>
-                    <Text type='tertiary'>安全提示</Text>
-                    <ul style={{ margin: '8px 0 0 16px', padding: 0 }}>
-                      <li>仅在信任的网络环境中授权。</li>
-                      <li>确认回调域名与申请方一致{info?.verified ? '(已验证)' : '(未验证)'}。</li>
-                      <li>你可以随时在账户设置中撤销授权。</li>
-                    </ul>
-                    <div style={{ marginTop: 12 }}>
-                      <Descriptions size='small' data={[{
-                        key: 'Issuer', value: window.location.origin,
-                      }, {
-                        key: 'Client ID', value: info?.client?.id || '-',
-                      }, {
-                        key: '需要PKCE', value: info?.require_pkce ? '是' : '否',
-                      }]} />
+
+                <Divider margin='0' />
+
+                {/* 用户信息 */}
+                <div className='px-5 py-3'>
+                  <div className='flex items-start justify-between'>
+                    <div className='flex items-start gap-3'>
+                      <div className='flex-1 min-w-0'>
+                        <Text className='block'>
+                          <Text strong>{info?.client?.name || info?.client?.id}</Text>
+                          {' '}{t('由')}{' '}
+                          <Text strong>{info?.client?.domain || t('未知域')}</Text>
+                        </Text>
+                        <Text type='tertiary' size='small' className='block mt-1'>
+                          {t('想要访问你的')} <Text strong>{info?.user?.name || ''}</Text> {t('账户')}
+                        </Text>
+                      </div>
                     </div>
+                    <Button size='small' theme='outline' type='tertiary' onClick={() => {
+                      const u = new URL(window.location.origin + '/login');
+                      u.searchParams.set('next', '/oauth/consent' + window.location.search);
+                      window.location.href = u.toString();
+                    }}>
+                      {t('切换账户')}
+                    </Button>
+                  </div>
+                </div>
+
+                <Divider margin='0' />
+
+                {/* 权限列表 */}
+                <div className='px-5 py-3'>
+                  <div className='space-y-2'>
+                    {info?.scope_info?.length ? (
+                      info.scope_info.map((scope) => (
+                        <ScopeItem
+                          key={scope.Name}
+                          name={scope.Name}
+                          description={scope.Description}
+                        />
+                      ))
+                    ) : (
+                      <div className='space-y-1'>
+                        {info?.scope_list?.map((name) => (
+                          <ScopeItem key={name} name={name} />
+                        ))}
+                      </div>
+                    )}
                   </div>
                 </div>
-              </div>
+              </Card>
 
-              <Divider />
-              <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingBottom: 8 }}>
-                <Button icon={<IconClose />} onClick={onDeny} theme='borderless'>
-                  拒绝
-                </Button>
-                <Button icon={<IconTickCircle />} type='primary' onClick={onApprove}>
-                  授权
-                </Button>
-              </div>
-            </div>
+              {/* Meta信息Card */}
+              <Card bordered={false}>
+                <div className='text-center'>
+                  <div className='flex flex-wrap justify-center gap-x-2 gap-y-1 items-center'>
+                    <Text size='small'>{t('客户端ID')}: {info?.client?.id?.slice(-8) || 'N/A'}</Text>
+                    <Dot size={16} />
+                    <Text size='small'>{t('类型')}: {info?.client?.type === 'public' ? t('公开应用') : t('机密应用')}</Text>
+                    {info?.response_type && (
+                      <>
+                        <Dot size={16} />
+                        <Text size='small'>{t('授权类型')}: {info.response_type === 'code' ? t('授权码') : info.response_type}</Text>
+                      </>
+                    )}
+                    {info?.require_pkce && (
+                      <>
+                        <Dot size={16} />
+                        <Text size='small'>PKCE: {t('已启用')}</Text>
+                      </>
+                    )}
+                  </div>
+                  {info?.state && (
+                    <div className='mt-2'>
+                      <Text type='tertiary' size='small' className='font-mono'>
+                        State: {info.state}
+                      </Text>
+                    </div>
+                  )}
+                </div>
+              </Card>
+            </>
           )
         )}
-      </Card>
+      </div>
     </div>
   );
 }

+ 0 - 123
web/src/pages/Setting/OAuth2/JWKSManager.jsx

@@ -1,123 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { Card, Table, Button, Space, Tag, Typography, Popconfirm, Toast } from '@douyinfe/semi-ui';
-import { IconRefresh, IconDelete, IconPlay } from '@douyinfe/semi-icons';
-import { API, showError, showSuccess } from '../../../helpers';
-
-const { Text } = Typography;
-
-export default function JWKSManager() {
-  const [loading, setLoading] = useState(false);
-  const [keys, setKeys] = useState([]);
-
-  const load = async () => {
-    setLoading(true);
-    try {
-      const res = await API.get('/api/oauth/keys');
-      if (res?.data?.success) {
-        setKeys(res.data.data || []);
-      } else {
-        showError(res?.data?.message || '获取密钥列表失败');
-      }
-    } catch (e) {
-      showError('获取密钥列表失败');
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  const rotate = async () => {
-    setLoading(true);
-    try {
-      const res = await API.post('/api/oauth/keys/rotate', {});
-      if (res?.data?.success) {
-        showSuccess('签名密钥已轮换:' + res.data.kid);
-        await load();
-      } else {
-        showError(res?.data?.message || '密钥轮换失败');
-      }
-    } catch (e) {
-      showError('密钥轮换失败');
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  const del = async (kid) => {
-    setLoading(true);
-    try {
-      const res = await API.delete(`/api/oauth/keys/${kid}`);
-      if (res?.data?.success) {
-        Toast.success('已删除:' + kid);
-        await load();
-      } else {
-        showError(res?.data?.message || '删除失败');
-      }
-    } catch (e) {
-      showError('删除失败');
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  useEffect(() => {
-    load();
-  }, []);
-
-  const columns = [
-    {
-      title: 'KID',
-      dataIndex: 'kid',
-      render: (kid) => <Text code copyable>{kid}</Text>,
-    },
-    {
-      title: '创建时间',
-      dataIndex: 'created_at',
-      render: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '-'),
-    },
-    {
-      title: '状态',
-      dataIndex: 'current',
-      render: (cur) => (cur ? <Tag color='green'>当前</Tag> : <Tag>历史</Tag>),
-    },
-    {
-      title: '操作',
-      render: (_, r) => (
-        <Space>
-          {!r.current && (
-            <Popconfirm
-              title={`确定删除密钥 ${r.kid} ?`}
-              content='删除后使用该 kid 签发的旧令牌仍可被验证(若 JWKS 已被其他方缓存,建议保留一段时间)'
-              okText='删除'
-              onConfirm={() => del(r.kid)}
-            >
-              <Button icon={<IconDelete />} size='small' theme='borderless'>删除</Button>
-            </Popconfirm>
-          )}
-        </Space>
-      ),
-    },
-  ];
-
-  return (
-    <Card
-      title='JWKS 管理'
-      extra={
-        <Space>
-          <Button icon={<IconRefresh />} onClick={load} loading={loading}>刷新</Button>
-          <Button icon={<IconPlay />} type='primary' onClick={rotate} loading={loading}>轮换密钥</Button>
-        </Space>
-      }
-      style={{ marginTop: 10 }}
-    >
-      <Table
-        dataSource={keys}
-        columns={columns}
-        rowKey='kid'
-        loading={loading}
-        pagination={false}
-        empty={<Text type='tertiary'>暂无密钥</Text>}
-      />
-    </Card>
-  );
-}
-

+ 0 - 437
web/src/pages/Setting/OAuth2/OAuth2ClientSettings.jsx

@@ -1,437 +0,0 @@
-/*
-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, { useEffect, useState } from 'react';
-import { 
-  Card, 
-  Table, 
-  Button, 
-  Space, 
-  Tag, 
-  Typography, 
-  Input, 
-  Popconfirm,
-  Modal,
-  Form,
-  Banner,
-  Row,
-  Col
-} from '@douyinfe/semi-ui';
-import { IconSearch, IconPlus } from '@douyinfe/semi-icons';
-import { API, showError, showSuccess, showInfo } from '../../../helpers';
-import CreateOAuth2ClientModal from '../../../components/modals/oauth2/CreateOAuth2ClientModal';
-import EditOAuth2ClientModal from '../../../components/modals/oauth2/EditOAuth2ClientModal';
-import { useTranslation } from 'react-i18next';
-
-const { Text, Title } = Typography;
-
-export default function OAuth2ClientSettings() {
-  const { t } = useTranslation();
-  const [loading, setLoading] = useState(false);
-  const [clients, setClients] = useState([]);
-  const [filteredClients, setFilteredClients] = useState([]);
-  const [searchKeyword, setSearchKeyword] = useState('');
-  const [showCreateModal, setShowCreateModal] = useState(false);
-  const [showEditModal, setShowEditModal] = useState(false);
-  const [editingClient, setEditingClient] = useState(null);
-  const [showSecretModal, setShowSecretModal] = useState(false);
-  const [currentSecret, setCurrentSecret] = useState('');
-
-  // 加载客户端列表
-  const loadClients = async () => {
-    setLoading(true);
-    try {
-      const res = await API.get('/api/oauth_clients/');
-      if (res.data.success) {
-        setClients(res.data.data || []);
-        setFilteredClients(res.data.data || []);
-      } else {
-        showError(res.data.message);
-      }
-    } catch (error) {
-      showError('加载OAuth2客户端失败');
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  // 搜索过滤
-  const handleSearch = (value) => {
-    setSearchKeyword(value);
-    if (!value) {
-      setFilteredClients(clients);
-    } else {
-      const filtered = clients.filter(client =>
-        client.name?.toLowerCase().includes(value.toLowerCase()) ||
-        client.id?.toLowerCase().includes(value.toLowerCase()) ||
-        client.description?.toLowerCase().includes(value.toLowerCase())
-      );
-      setFilteredClients(filtered);
-    }
-  };
-
-  // 删除客户端
-  const handleDelete = async (client) => {
-    try {
-      const res = await API.delete(`/api/oauth_clients/${client.id}`);
-      if (res.data.success) {
-        showSuccess('删除成功');
-        loadClients();
-      } else {
-        showError(res.data.message);
-      }
-    } catch (error) {
-      showError('删除失败');
-    }
-  };
-
-  // 重新生成密钥
-  const handleRegenerateSecret = async (client) => {
-    try {
-      const res = await API.post(`/api/oauth_clients/${client.id}/regenerate_secret`);
-      if (res.data.success) {
-        setCurrentSecret(res.data.client_secret);
-        setShowSecretModal(true);
-        loadClients();
-      } else {
-        showError(res.data.message);
-      }
-    } catch (error) {
-      showError('重新生成密钥失败');
-    }
-  };
-
-  // 表格列定义
-  const columns = [
-    {
-      title: '客户端名称',
-      dataIndex: 'name',
-      key: 'name',
-      render: (text, record) => (
-        <div>
-          <Text strong>{text}</Text>
-          <br />
-          <Text type="tertiary" size="small">{record.id}</Text>
-        </div>
-      ),
-    },
-    {
-      title: '类型',
-      dataIndex: 'client_type',
-      key: 'client_type',
-      render: (text) => (
-        <Tag color={text === 'confidential' ? 'blue' : 'green'}>
-          {text === 'confidential' ? '机密客户端' : '公开客户端'}
-        </Tag>
-      ),
-    },
-    {
-      title: '授权类型',
-      dataIndex: 'grant_types',
-      key: 'grant_types',
-      render: (grantTypes) => {
-        const types = typeof grantTypes === 'string' ? grantTypes.split(',') : (grantTypes || []);
-        return (
-          <div>
-            {types.map(type => (
-              <Tag key={type} size="small" style={{ margin: '2px' }}>
-                {type === 'client_credentials' ? '客户端凭证' :
-                 type === 'authorization_code' ? '授权码' :
-                 type === 'refresh_token' ? '刷新令牌' : type}
-              </Tag>
-            ))}
-          </div>
-        );
-      },
-    },
-    {
-      title: '状态',
-      dataIndex: 'status',
-      key: 'status',
-      render: (status) => (
-        <Tag color={status === 1 ? 'green' : 'red'}>
-          {status === 1 ? '启用' : '禁用'}
-        </Tag>
-      ),
-    },
-    {
-      title: '创建时间',
-      dataIndex: 'created_time',
-      key: 'created_time',
-      render: (time) => new Date(time * 1000).toLocaleString(),
-    },
-    {
-      title: '操作',
-      key: 'action',
-      render: (_, record) => (
-        <Space>
-          <Button
-            theme="borderless"
-            type="primary"
-            size="small"
-            onClick={() => {
-              setEditingClient(record);
-              setShowEditModal(true);
-            }}
-          >
-            编辑
-          </Button>
-          {record.client_type === 'confidential' && (
-            <Popconfirm
-              title="确认重新生成客户端密钥?"
-              content={
-                <div>
-                  <div>客户端:{record.name}</div>
-                  <div style={{ marginTop: 6 }}>操作不可撤销,旧密钥将立即失效。</div>
-                </div>
-              }
-              onConfirm={() => handleRegenerateSecret(record)}
-              okText="确认"
-              cancelText="取消"
-            >
-              <Button
-                theme="borderless"
-                type="secondary"
-                size="small"
-              >
-                重新生成密钥
-              </Button>
-            </Popconfirm>
-          )}
-          <Popconfirm
-            title="请再次确认删除该客户端"
-            content={
-              <div>
-                <div>客户端:{record.name}</div>
-                <div style={{ marginTop: 6, color: 'var(--semi-color-danger)' }}>删除后无法恢复,相关 API 调用将立即失效。</div>
-              </div>
-            }
-            onConfirm={() => handleDelete(record)}
-            okText="确定删除"
-            cancelText="取消"
-          >
-            <Button
-              theme="borderless"
-              type="danger"
-              size="small"
-            >
-              删除
-            </Button>
-          </Popconfirm>
-        </Space>
-      ),
-    },
-  ];
-
-  useEffect(() => {
-    loadClients();
-  }, []);
-
-  return (
-    <div>
-      <Card style={{ marginTop: 10 }}>
-        <Form.Section text={'OAuth2 客户端管理'}>
-        <Banner
-          type="info"
-          description="管理OAuth2客户端应用程序,每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用,公开客户端用于移动应用或单页应用。"
-          style={{ marginBottom: 15 }}
-        />
-        
-        <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} style={{ marginBottom: 16 }}>
-          <Col xs={24} sm={24} md={12} lg={8} xl={8}>
-            <Input
-              prefix={<IconSearch />}
-              placeholder="搜索客户端名称、ID或描述"
-              value={searchKeyword}
-              onChange={handleSearch}
-              showClear
-            />
-          </Col>
-          <Col xs={24} sm={24} md={12} lg={16} xl={16} style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
-            <Button onClick={loadClients}>刷新</Button>
-            <Button
-              type="primary"
-              icon={<IconPlus />}
-              onClick={() => setShowCreateModal(true)}
-            >
-              创建OAuth2客户端
-            </Button>
-          </Col>
-        </Row>
-
-        <Table
-          columns={columns}
-          dataSource={filteredClients}
-          rowKey="id"
-          loading={loading}
-          pagination={{
-            showSizeChanger: true,
-            showQuickJumper: true,
-            showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
-            pageSize: 10,
-          }}
-          empty={
-            <div style={{ textAlign: 'center', padding: '50px 0' }}>
-              <Text type="tertiary">暂无OAuth2客户端</Text>
-              <br />
-              <Button
-                type="primary"
-                icon={<IconPlus />}
-                onClick={() => setShowCreateModal(true)}
-                style={{ marginTop: 10 }}
-              >
-                创建第一个客户端
-              </Button>
-            </div>
-          }
-        />
-
-        {/* 快速操作 */}
-        <div style={{ marginTop: 20, marginBottom: 10 }}>
-          <Text strong>快速操作</Text>
-        </div>
-        <div style={{ marginBottom: 20 }}>
-          <Space wrap>
-            <Button 
-              type="tertiary"
-              onClick={async () => {
-                try {
-                  const res = await API.get('/api/oauth/jwks');
-                  Modal.info({
-                    title: 'JWKS信息',
-                    content: (
-                      <div>
-                        <Text>JSON Web Key Set:</Text>
-                        <pre style={{ 
-                          background: '#f8f9fa', 
-                          padding: '12px', 
-                          borderRadius: '4px',
-                          marginTop: '8px',
-                          fontSize: '12px',
-                          maxHeight: '300px',
-                          overflow: 'auto'
-                        }}>
-                          {JSON.stringify(res.data, null, 2)}
-                        </pre>
-                      </div>
-                    ),
-                    width: 600
-                  });
-                } catch (error) {
-                  showError('获取JWKS失败');
-                }
-              }}
-            >
-              查看JWKS
-            </Button>
-            <Button 
-              type="tertiary"
-              onClick={async () => {
-                try {
-                  const res = await API.get('/api/oauth/server-info');
-                  Modal.info({
-                    title: 'OAuth2服务器信息',
-                    content: (
-                      <div>
-                        <Text>授权服务器配置:</Text>
-                        <pre style={{ 
-                          background: '#f8f9fa', 
-                          padding: '12px', 
-                          borderRadius: '4px',
-                          marginTop: '8px',
-                          fontSize: '12px',
-                          maxHeight: '300px',
-                          overflow: 'auto'
-                        }}>
-                          {JSON.stringify(res.data, null, 2)}
-                        </pre>
-                      </div>
-                    ),
-                    width: 600
-                  });
-                } catch (error) {
-                  showError('获取服务器信息失败');
-                }
-              }}
-            >
-              查看服务器信息
-            </Button>
-            <Button 
-              type="tertiary"
-              onClick={() => showInfo('OAuth2集成文档功能开发中,请参考相关API文档')}
-            >
-              集成文档
-            </Button>
-          </Space>
-        </div>
-      </Form.Section>
-    </Card>
-
-      {/* 创建客户端模态框 */}
-      <CreateOAuth2ClientModal
-        visible={showCreateModal}
-        onCancel={() => setShowCreateModal(false)}
-        onSuccess={() => {
-          setShowCreateModal(false);
-          loadClients();
-        }}
-      />
-
-      {/* 编辑客户端模态框 */}
-      <EditOAuth2ClientModal
-        visible={showEditModal}
-        client={editingClient}
-        onCancel={() => {
-          setShowEditModal(false);
-          setEditingClient(null);
-        }}
-        onSuccess={() => {
-          setShowEditModal(false);
-          setEditingClient(null);
-          loadClients();
-        }}
-      />
-
-      {/* 密钥显示模态框 */}
-      <Modal
-        title="客户端密钥已重新生成"
-        visible={showSecretModal}
-        onCancel={() => setShowSecretModal(false)}
-        onOk={() => setShowSecretModal(false)}
-        cancelText=""
-        okText="我已复制保存"
-        width={600}
-      >
-        <div>
-          <Text>新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。</Text>
-          <div style={{ 
-            background: '#f8f9fa', 
-            padding: '16px', 
-            borderRadius: '6px',
-            marginTop: '16px',
-            fontFamily: 'monospace',
-            wordBreak: 'break-all'
-          }}>
-            <Text code copyable>{currentSecret}</Text>
-          </div>
-        </div>
-      </Modal>
-    </div>
-  );
-}

+ 0 - 131
web/src/pages/Setting/OAuth2/OAuth2QuickStart.jsx

@@ -1,131 +0,0 @@
-import React, { useMemo, useState } from 'react';
-import { Card, Typography, Button, Space, Steps, Form, Input, Select, Tag, Toast } from '@douyinfe/semi-ui';
-import { API, showError, showSuccess } from '../../../helpers';
-
-const { Title, Text } = Typography;
-
-export default function OAuth2QuickStart({ onChanged }) {
-  const [busy, setBusy] = useState(false);
-  const origin = useMemo(() => window.location.origin, []);
-  const [client, setClient] = useState({
-    name: 'Default OIDC Client',
-    client_type: 'public',
-    redirect_uris: [origin + '/oauth/oidc', ''],
-    scopes: ['openid', 'profile', 'email', 'api:read'],
-  });
-
-  const applyRecommended = async () => {
-    setBusy(true);
-    try {
-      const ops = [
-        { key: 'oauth2.enabled', value: 'true' },
-        { key: 'oauth2.issuer', value: origin },
-        { key: 'oauth2.allowed_grant_types', value: JSON.stringify(['authorization_code', 'refresh_token', 'client_credentials']) },
-        { key: 'oauth2.require_pkce', value: 'true' },
-        { key: 'oauth2.jwt_signing_algorithm', value: 'RS256' },
-      ];
-      for (const op of ops) {
-        await API.put('/api/option/', op);
-      }
-      showSuccess('已应用推荐配置');
-      onChanged && onChanged();
-    } catch (e) {
-      showError('应用推荐配置失败');
-    } finally {
-      setBusy(false);
-    }
-  };
-
-  const ensureKey = async () => {
-    setBusy(true);
-    try {
-      const res = await API.get('/api/oauth/keys');
-      const list = res?.data?.data || [];
-      if (list.length === 0) {
-        const r = await API.post('/api/oauth/keys/rotate', {});
-        if (r?.data?.success) showSuccess('已初始化签名密钥');
-      } else {
-        const r = await API.post('/api/oauth/keys/rotate', {});
-        if (r?.data?.success) showSuccess('已轮换签名密钥:' + r.data.kid);
-      }
-    } catch (e) {
-      showError('签名密钥操作失败');
-    } finally {
-      setBusy(false);
-    }
-  };
-
-  const createClient = async () => {
-    setBusy(true);
-    try {
-      const grant_types = client.client_type === 'public'
-        ? ['authorization_code', 'refresh_token']
-        : ['authorization_code', 'refresh_token', 'client_credentials'];
-      const payload = {
-        name: client.name,
-        client_type: client.client_type,
-        grant_types,
-        redirect_uris: client.redirect_uris.filter(Boolean),
-        scopes: client.scopes,
-        require_pkce: true,
-      };
-      const res = await API.post('/api/oauth_clients/', payload);
-      if (res?.data?.success) {
-        Toast.success('客户端已创建:' + res.data.client_id);
-        onChanged && onChanged();
-      } else {
-        showError(res?.data?.message || '创建失败');
-      }
-    } catch (e) {
-      showError('创建失败');
-    } finally {
-      setBusy(false);
-    }
-  };
-
-  return (
-    <Card style={{ marginTop: 10 }}>
-      <Title heading={5} style={{ marginBottom: 8 }}>OAuth2 一键初始化</Title>
-      <Text type='tertiary'>按顺序完成以下步骤,系统将自动完成推荐设置、签名密钥准备、客户端创建与回调配置。</Text>
-      <div style={{ marginTop: 12 }}>
-        <Steps current={-1} type='basic' direction='vertical'>
-          <Steps.Step title='应用推荐配置' description='启用 OAuth2,设置发行人(Issuer)为当前域名,启用授权码+PKCE、刷新令牌、客户端凭证。'>
-            <Button onClick={applyRecommended} loading={busy} style={{ marginTop: 8 }}>一键应用</Button>
-            <div style={{ marginTop: 8 }}>
-              <Tag>issuer = {origin}</Tag>{' '}
-              <Tag>grant_types = auth_code / refresh_token / client_credentials</Tag>{' '}
-              <Tag>PKCE = S256</Tag>
-            </div>
-          </Steps.Step>
-          <Steps.Step title='准备签名密钥' description='若无密钥则初始化;如已存在,建议立即轮换以生成新的 kid。'>
-            <Button onClick={ensureKey} loading={busy} style={{ marginTop: 8 }}>初始化/轮换</Button>
-          </Steps.Step>
-          <Steps.Step title='创建 OIDC 客户端' description='创建一个默认客户端,预置常用回调与 scope,可直接用于调试与集成。'>
-            <Form labelPosition='left' labelWidth={120} style={{ marginTop: 8 }}>
-              <Form.Input label='名称' value={client.name} onChange={(v)=>setClient({...client, name: v})} />
-              <Form.Select label='类型' value={client.client_type} onChange={(v)=>setClient({...client, client_type: v})}>
-                <Select.Option value='public'>公开客户端</Select.Option>
-                <Select.Option value='confidential'>机密客户端</Select.Option>
-              </Form.Select>
-              <Form.Input label='回调 URI 1' value={client.redirect_uris[0]} onChange={(v)=>{
-                const arr=[...client.redirect_uris]; arr[0]=v; setClient({...client, redirect_uris: arr});
-              }} />
-              <Form.Input label='回调 URI 2' value={client.redirect_uris[1]} onChange={(v)=>{
-                const arr=[...client.redirect_uris]; arr[1]=v; setClient({...client, redirect_uris: arr});
-              }} />
-              <Form.Select label='Scopes' multiple value={client.scopes} onChange={(v)=>setClient({...client, scopes: v})}>
-                <Select.Option value='openid'>openid</Select.Option>
-                <Select.Option value='profile'>profile</Select.Option>
-                <Select.Option value='email'>email</Select.Option>
-                <Select.Option value='api:read'>api:read</Select.Option>
-                <Select.Option value='api:write'>api:write</Select.Option>
-                <Select.Option value='admin'>admin</Select.Option>
-              </Form.Select>
-            </Form>
-            <Button type='primary' onClick={createClient} loading={busy} style={{ marginTop: 8 }}>创建默认客户端</Button>
-          </Steps.Step>
-        </Steps>
-      </div>
-    </Card>
-  );
-}

+ 0 - 404
web/src/pages/Setting/OAuth2/OAuth2ServerSettings.jsx

@@ -1,404 +0,0 @@
-/*
-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, { useEffect, useState, useRef } from 'react';
-import { Banner, Button, Col, Form, Row, Card } from '@douyinfe/semi-ui';
-import {
-  compareObjects,
-  API,
-  showError,
-  showSuccess,
-  showWarning,
-} from '../../../helpers';
-import { useTranslation } from 'react-i18next';
-
-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.jwt_private_key_file': '',
-    'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code', 'refresh_token'],
-    'oauth2.require_pkce': true,
-    'oauth2.auto_create_user': false,
-    'oauth2.default_user_role': 1,
-    'oauth2.default_user_group': 'default',
-    'oauth2.max_jwks_keys': 3,
-  });
-  const refForm = useRef();
-  const [inputsRow, setInputsRow] = useState(inputs);
-  const [keysReady, setKeysReady] = useState(true);
-  const [keysLoading, setKeysLoading] = 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 () => {
-    try {
-      const res = await API.get('/api/oauth/server-info');
-      // 只要返回了issuer等关键字段即可视为成功
-      if (res.status === 200 && (res.data.issuer || res.data.authorization_endpoint)) {
-        showSuccess('OAuth2服务器运行正常');
-      } else {
-        showError('OAuth2服务器测试失败');
-      }
-    } catch (error) {
-      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(() => {
-    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();
-    }
-  }, [inputs['oauth2.enabled']]);
-
-  return (
-    <div>
-      {/* 移除重复的顶卡片,统一在下方“基础配置”中显示开关与 Issuer */}
-
-      {/* 开关与基础配置 */}
-      <Card style={{ marginTop: 10 }}>
-        <Form
-          initValues={inputs}
-          getFormApi={(formAPI) => (refForm.current = formAPI)}
-        >
-          <Form.Section text={'基础配置'}>
-            {!keysReady && inputs['oauth2.enabled'] && (
-              <Banner
-                type='warning'
-                description={<div>
-                  <div>尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。</div>
-                  <div>签名密钥用于 JWT 令牌的安全签发。</div>
-                </div>}
-                actions={<Button size='small' onClick={() => props?.onOpenJWKS && props.onOpenJWKS()} loading={keysLoading}>打开密钥向导</Button>}
-                style={{ marginBottom: 12 }}
-              />
-            )}
-            <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
-              <Col xs={24} sm={24} md={12} lg={12} xl={12}>
-                <Form.Switch
-                  field='oauth2.enabled'
-                  label={t('启用 OAuth2 & SSO')}
-                  checkedText='开'
-                  uncheckedText='关'
-                  value={inputs['oauth2.enabled']}
-                  onChange={handleFieldChange('oauth2.enabled')}
-                  extraText="开启后将允许以 OAuth2/OIDC 标准进行授权与登录"
-                />
-              </Col>
-              <Col xs={24} sm={24} md={12} lg={12} xl={12}>
-                <Form.Input
-                  field='oauth2.issuer'
-                  label={t('发行人 (Issuer)')}
-                  placeholder={window.location.origin}
-                  value={inputs['oauth2.issuer']}
-                  onChange={handleFieldChange('oauth2.issuer')}
-                  extraText="为空则按请求自动推断(含 X-Forwarded-Proto)"
-                />
-              </Col>
-            </Row>
-            <Button onClick={onSubmit} loading={loading}>{t('更新基础配置')}</Button>
-          </Form.Section>
-        </Form>
-      </Card>
-
-      {inputs['oauth2.enabled'] && (
-      <>
-      <Card style={{ marginTop: 10 }}>
-        <Form
-          initValues={inputs}
-          getFormApi={(formAPI) => (refForm.current = formAPI)}
-        >
-          <Form.Section text={'令牌配置'}>
-            <Banner
-              type='info'
-              description={<div>
-                <div>• OAuth2 服务器提供标准的 API 认证与授权</div>
-                <div>• 支持 Client Credentials、Authorization Code + PKCE 等标准流程</div>
-                <div>• 配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作;一般无需重启</div>
-                <div>• 生产环境务必启用 HTTPS,并妥善管理 JWT 签名密钥</div>
-              </div>}
-              style={{ marginBottom: 12 }}
-            />
-            <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
-              <Col xs={24} sm={24} md={8} lg={8} xl={8}>
-                <Form.InputNumber
-                  field='oauth2.access_token_ttl'
-                  label={t('访问令牌有效期')}
-                  suffix="分钟"
-                  min={1}
-                  max={1440}
-                  value={inputs['oauth2.access_token_ttl']}
-                  onChange={handleFieldChange('oauth2.access_token_ttl')}
-                  extraText="访问令牌的有效时间,建议较短(10-60分钟)"
-                />
-              </Col>
-              <Col xs={24} sm={24} md={8} lg={8} xl={8}>
-                <Form.InputNumber
-                  field='oauth2.refresh_token_ttl'
-                  label={t('刷新令牌有效期')}
-                  suffix="小时"
-                  min={1}
-                  max={8760}
-                  value={inputs['oauth2.refresh_token_ttl']}
-                  onChange={handleFieldChange('oauth2.refresh_token_ttl')}
-                  extraText="刷新令牌的有效时间,建议较长(12-720小时)"
-                />
-              </Col>
-              <Col xs={24} sm={24} md={8} lg={8} xl={8}>
-                <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="用于标识JWT签名密钥,支持密钥轮换"
-                />
-              </Col>
-            </Row>
-            <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
-              <Col xs={24} sm={24} md={12} lg={12} xl={12}>
-                <Form.Select
-                  field='oauth2.jwt_signing_algorithm'
-                  label={t('JWT签名算法')}
-                  value={inputs['oauth2.jwt_signing_algorithm']}
-                  onChange={handleFieldChange('oauth2.jwt_signing_algorithm')}
-                  extraText="JWT令牌的签名算法,推荐使用RS256"
-                >
-                  <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} sm={24} md={12} lg={12} xl={12}>*/}
-              {/*  <Form.Input*/}
-              {/*    field='oauth2.jwt_private_key_file'*/}
-              {/*    label={t('JWT私钥文件路径(可选)')}*/}
-              {/*    placeholder="/path/to/oauth2-private-key.pem"*/}
-              {/*    value={inputs['oauth2.jwt_private_key_file']}*/}
-              {/*    onChange={handleFieldChange('oauth2.jwt_private_key_file')}*/}
-              {/*    extraText="如需外部文件私钥,可在此指定路径;推荐使用内存密钥 + JWKS 轮换(更安全便捷)"*/}
-              {/*  />*/}
-              {/*</Col>*/}
-            </Row>
-            <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
-              <Col xs={24} sm={24} md={12} lg={12} xl={12}>
-                <Form.InputNumber
-                  field='oauth2.max_jwks_keys'
-                  label='JWKS历史保留上限'
-                  min={1}
-                  max={10}
-                  value={inputs['oauth2.max_jwks_keys']}
-                  onChange={handleFieldChange('oauth2.max_jwks_keys')}
-                  extraText="轮换后最多保留的历史签名密钥数量(越少越安全,建议 3)"
-                />
-              </Col>
-            </Row>
-            <div style={{ display: 'flex', gap: 8 }}>
-              <Button onClick={onSubmit} loading={loading}>{t('更新令牌配置')}</Button>
-              <Button type='secondary' onClick={() => props && props.onOpenJWKS && props.onOpenJWKS()}>密钥向导(JWKS)</Button>
-            </div>
-          </Form.Section>
-        </Form>
-      </Card>
-
-      <Card style={{ marginTop: 10 }}>
-        <Form
-          initValues={inputs}
-          getFormApi={(formAPI) => (refForm.current = formAPI)}
-        >
-          <Form.Section text={'授权配置'}>
-            <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
-              <Col xs={24} sm={24} md={12} lg={12} xl={12}>
-                <Form.Select
-                  field='oauth2.allowed_grant_types'
-                  label={t('允许的授权类型')}
-                  multiple
-                  value={inputs['oauth2.allowed_grant_types']}
-                  onChange={handleFieldChange('oauth2.allowed_grant_types')}
-                  extraText="选择允许的OAuth2授权流程"
-                >
-                  <Form.Select.Option value="client_credentials">Client Credentials(客户端凭证)</Form.Select.Option>
-                  <Form.Select.Option value="authorization_code">Authorization Code(授权码)</Form.Select.Option>
-                  <Form.Select.Option value="refresh_token">Refresh Token(刷新令牌)</Form.Select.Option>
-                </Form.Select>
-              </Col>
-              <Col xs={24} sm={24} md={12} lg={12} xl={12}>
-                <Form.Switch
-                  field='oauth2.require_pkce'
-                  label={t('强制PKCE验证')}
-                  checkedText='开'
-                  uncheckedText='关'
-                  value={inputs['oauth2.require_pkce']}
-                  onChange={handleFieldChange('oauth2.require_pkce')}
-                  extraText="为授权码流程强制启用PKCE,提高安全性"
-                />
-              </Col>
-            </Row>
-            <Button onClick={onSubmit} loading={loading}>{t('更新授权配置')}</Button>
-          </Form.Section>
-        </Form>
-      </Card>
-
-      {/*<Card style={{ marginTop: 10 }}>*/}
-      {/*  <Form*/}
-      {/*    initValues={inputs}*/}
-      {/*    getFormApi={(formAPI) => (refForm.current = formAPI)}*/}
-      {/*  >*/}
-      {/*    <Form.Section text={'用户配置'}>*/}
-      {/*      <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>*/}
-      {/*        <Col xs={24} sm={24} md={8} lg={8} xl={8}>*/}
-      {/*          <Form.Switch*/}
-      {/*            field='oauth2.auto_create_user'*/}
-      {/*            label={t('自动创建用户')}*/}
-      {/*            checkedText='开'*/}
-      {/*            uncheckedText='关'*/}
-      {/*            value={inputs['oauth2.auto_create_user']}*/}
-      {/*            onChange={handleFieldChange('oauth2.auto_create_user')}*/}
-      {/*            extraText="首次OAuth2登录时自动创建用户账户"*/}
-      {/*          />*/}
-      {/*        </Col>*/}
-      {/*        <Col xs={24} sm={24} md={8} lg={8} xl={8}>*/}
-      {/*          <Form.Select*/}
-      {/*            field='oauth2.default_user_role'*/}
-      {/*            label={t('默认用户角色')}*/}
-      {/*            value={inputs['oauth2.default_user_role']}*/}
-      {/*            onChange={handleFieldChange('oauth2.default_user_role')}*/}
-      {/*            extraText="自动创建用户时的默认角色"*/}
-      {/*          >*/}
-      {/*            <Form.Select.Option value={1}>普通用户</Form.Select.Option>*/}
-      {/*            <Form.Select.Option value={10}>管理员</Form.Select.Option>*/}
-      {/*            <Form.Select.Option value={100}>超级管理员</Form.Select.Option>*/}
-      {/*          </Form.Select>*/}
-      {/*        </Col>*/}
-      {/*        <Col xs={24} sm={24} md={8} lg={8} xl={8}>*/}
-      {/*          <Form.Input*/}
-      {/*            field='oauth2.default_user_group'*/}
-      {/*            label={t('默认用户分组')}*/}
-      {/*            placeholder="default"*/}
-      {/*            value={inputs['oauth2.default_user_group']}*/}
-      {/*            onChange={handleFieldChange('oauth2.default_user_group')}*/}
-      {/*            extraText="自动创建用户时的默认分组"*/}
-      {/*          />*/}
-      {/*        </Col>*/}
-      {/*      </Row>*/}
-      {/*      <Button onClick={onSubmit} loading={loading}>{t('更新用户配置')}</Button>*/}
-      {/*      <Button*/}
-      {/*        type="secondary"*/}
-      {/*        onClick={testOAuth2}*/}
-      {/*        style={{ marginLeft: 8 }}*/}
-      {/*      >*/}
-      {/*        {t('测试连接')}*/}
-      {/*      </Button>*/}
-      {/*    </Form.Section>*/}
-      {/*  </Form>*/}
-      {/*</Card>*/}
-      </>
-      )}
-    </div>
-  );
-}

+ 0 - 129
web/src/pages/Setting/OAuth2/OAuth2Tools.jsx

@@ -1,129 +0,0 @@
-import React, { useEffect, useMemo, useState } from 'react';
-import { Card, Form, Input, Button, Space, Typography, Divider, Toast, Select } from '@douyinfe/semi-ui';
-import { API } from '../../../helpers';
-
-const { Text } = Typography;
-
-async function sha256Base64Url(input) {
-  const enc = new TextEncoder();
-  const data = enc.encode(input);
-  const hash = await crypto.subtle.digest('SHA-256', data);
-  const bytes = new Uint8Array(hash);
-  let binary = '';
-  for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
-  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
-}
-
-function randomString(len = 43) {
-  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
-  let res = '';
-  const array = new Uint32Array(len);
-  crypto.getRandomValues(array);
-  for (let i = 0; i < len; i++) res += charset[array[i] % charset.length];
-  return res;
-}
-
-export default function OAuth2Tools() {
-  const [loading, setLoading] = useState(false);
-  const [server, setServer] = useState({});
-  const [values, setValues] = useState({
-    authorization_endpoint: '',
-    token_endpoint: '',
-    client_id: '',
-    redirect_uri: window.location.origin + '/oauth/oidc',
-    scope: 'openid profile email',
-    response_type: 'code',
-    code_verifier: '',
-    code_challenge: '',
-    code_challenge_method: 'S256',
-    state: '',
-    nonce: '',
-  });
-
-  useEffect(() => {
-    (async () => {
-      try {
-        const res = await API.get('/api/oauth/server-info');
-        if (res?.data) {
-          const d = res.data;
-          setServer(d);
-          setValues((v) => ({
-            ...v,
-            authorization_endpoint: d.authorization_endpoint,
-            token_endpoint: d.token_endpoint,
-          }));
-        }
-      } catch {}
-    })();
-  }, []);
-
-  const buildAuthorizeURL = () => {
-    const u = new URL(values.authorization_endpoint || (server.issuer + '/oauth/authorize'));
-    u.searchParams.set('response_type', values.response_type || 'code');
-    u.searchParams.set('client_id', values.client_id);
-    u.searchParams.set('redirect_uri', values.redirect_uri);
-    u.searchParams.set('scope', values.scope);
-    if (values.state) u.searchParams.set('state', values.state);
-    if (values.nonce) u.searchParams.set('nonce', values.nonce);
-    if (values.code_challenge) {
-      u.searchParams.set('code_challenge', values.code_challenge);
-      u.searchParams.set('code_challenge_method', values.code_challenge_method || 'S256');
-    }
-    return u.toString();
-  };
-
-  const copy = async (text, tip = '已复制') => {
-    try {
-      await navigator.clipboard.writeText(text);
-      Toast.success(tip);
-    } catch {}
-  };
-
-  const genVerifier = async () => {
-    const v = randomString(64);
-    const c = await sha256Base64Url(v);
-    setValues((val) => ({ ...val, code_verifier: v, code_challenge: c }));
-  };
-
-  return (
-    <Card style={{ marginTop: 10 }} title='OAuth2 调试助手'>
-      <Form labelPosition='left' labelWidth={140}>
-        <Form.Input field='authorization_endpoint' label='Authorize URL' value={values.authorization_endpoint} onChange={(v)=>setValues({...values, authorization_endpoint: v})} />
-        <Form.Input field='token_endpoint' label='Token URL' value={values.token_endpoint} onChange={(v)=>setValues({...values, token_endpoint: v})} />
-        <Form.Input field='client_id' label='Client ID' placeholder='输入 client_id' value={values.client_id} onChange={(v)=>setValues({...values, client_id: v})} />
-        <Form.Input field='redirect_uri' label='Redirect URI' value={values.redirect_uri} onChange={(v)=>setValues({...values, redirect_uri: v})} />
-        <Form.Input field='scope' label='Scope' value={values.scope} onChange={(v)=>setValues({...values, scope: v})} />
-        <Form.Select field='code_challenge_method' label='PKCE 方法' value={values.code_challenge_method} onChange={(v)=>setValues({...values, code_challenge_method: v})}>
-          <Select.Option value='S256'>S256</Select.Option>
-        </Form.Select>
-        <Form.Input field='code_verifier' label='Code Verifier' value={values.code_verifier} onChange={(v)=>setValues({...values, code_verifier: v})} suffix={
-          <Button size='small' onClick={genVerifier}>生成</Button>
-        } />
-        <Form.Input field='code_challenge' label='Code Challenge' value={values.code_challenge} onChange={(v)=>setValues({...values, code_challenge: v})} />
-        <Form.Input field='state' label='State' value={values.state} onChange={(v)=>setValues({...values, state: v})} suffix={<Button size='small' onClick={()=>setValues({...values, state: randomString(16)})}>随机</Button>} />
-        <Form.Input field='nonce' label='Nonce' value={values.nonce} onChange={(v)=>setValues({...values, nonce: v})} suffix={<Button size='small' onClick={()=>setValues({...values, nonce: randomString(16)})}>随机</Button>} />
-      </Form>
-      <Divider />
-      <Space>
-        <Button onClick={()=>window.open(buildAuthorizeURL(), '_blank')}>打开授权URL</Button>
-        <Button onClick={()=>copy(buildAuthorizeURL(), '授权URL已复制')}>复制授权URL</Button>
-        <Button onClick={()=>copy(JSON.stringify({
-          authorize_url: values.authorization_endpoint,
-          token_url: values.token_endpoint,
-          client_id: values.client_id,
-          redirect_uri: values.redirect_uri,
-          scope: values.scope,
-          code_challenge_method: values.code_challenge_method,
-          code_verifier: values.code_verifier,
-          code_challenge: values.code_challenge,
-          state: values.state,
-          nonce: values.nonce,
-        }, null, 2), 'oauthdebugger参数已复制')}>复制 oauthdebugger 参数</Button>
-      </Space>
-      <Text type='tertiary' style={{ display: 'block', marginTop: 8 }}>
-        提示:将上述参数粘贴到 oauthdebugger.com,或直接打开授权URL完成授权后回调。
-      </Text>
-    </Card>
-  );
-}
-