Răsfoiți Sursa

🎨 refactor(oauth2): merge modals and improve UI consistency

This commit consolidates OAuth2 client management components and
enhances the overall user experience with improved UI consistency.

### Major Changes:

**Component Consolidation:**
- Merge CreateOAuth2ClientModal.jsx and EditOAuth2ClientModal.jsx into OAuth2ClientModal.jsx
- Extract inline Modal.info into dedicated ClientInfoModal.jsx component
- Adopt consistent SideSheet + Card layout following EditTokenModal.jsx style

**UI/UX Improvements:**
- Replace custom client type selection with SemiUI RadioGroup component
- Use 'card' type RadioGroup with descriptive 'extra' prop for better UX
- Remove all Row/Col components in favor of flexbox and margin-based layouts
- Refactor redirect URI section to mimic JSONEditor.jsx visual style
- Add responsive design support for mobile devices

**Form Enhancements:**
- Add 'required' attributes to all mandatory form fields
- Implement placeholders for grant types, scopes, and redirect URI inputs
- Set grant types and scopes to default empty arrays
- Add dynamic validation and conditional rendering for client types
- Improve redirect URI management with template filling functionality

**Bug Fixes:**
- Fix SideSheet closing direction consistency between create/edit modes
- Resolve client_type submission issue (object vs string)
- Prevent "Client Credentials" selection for public clients
- Fix grant type filtering when switching between client types
- Resolve i18n issues for API scope options (api:read, api:write)

**Code Quality:**
- Extract RedirectUriCard as reusable sub-component
- Add comprehensive internationalization support
- Implement proper state management and form validation
- Follow single responsibility principle for component separation

**Files Modified:**
- web/src/components/settings/oauth2/modals/OAuth2ClientModal.jsx
- web/src/components/settings/oauth2/modals/ClientInfoModal.jsx (new)
- web/src/components/settings/oauth2/OAuth2ClientSettings.jsx
- web/src/i18n/locales/en.json

**Files Removed:**
- web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx
- web/src/components/settings/oauth2/modals/EditOAuth2ClientModal.jsx

This refactoring significantly improves code maintainability, reduces
duplication, and provides a more consistent and intuitive user interface
for OAuth2 client management.
t0ng7u 3 luni în urmă
părinte
comite
315eabc1e7

+ 19 - 22
web/src/components/settings/oauth2/OAuth2ClientSettings.jsx

@@ -37,8 +37,7 @@ import {
   IllustrationNoResultDark,
 } from '@douyinfe/semi-illustrations';
 import { API, showError, showSuccess } from '../../../helpers';
-import CreateOAuth2ClientModal from './modals/CreateOAuth2ClientModal';
-import EditOAuth2ClientModal from './modals/EditOAuth2ClientModal';
+import OAuth2ClientModal from './modals/OAuth2ClientModal';
 import SecretDisplayModal from './modals/SecretDisplayModal';
 import ServerInfoModal from './modals/ServerInfoModal';
 import JWKSInfoModal from './modals/JWKSInfoModal';
@@ -52,8 +51,7 @@ export default function OAuth2ClientSettings() {
   const [clients, setClients] = useState([]);
   const [filteredClients, setFilteredClients] = useState([]);
   const [searchKeyword, setSearchKeyword] = useState('');
-  const [showCreateModal, setShowCreateModal] = useState(false);
-  const [showEditModal, setShowEditModal] = useState(false);
+  const [showModal, setShowModal] = useState(false);
   const [editingClient, setEditingClient] = useState(null);
   const [showSecretModal, setShowSecretModal] = useState(false);
   const [currentSecret, setCurrentSecret] = useState('');
@@ -228,7 +226,7 @@ export default function OAuth2ClientSettings() {
             size='small'
             onClick={() => {
               setEditingClient(record);
-              setShowEditModal(true);
+              setShowModal(true);
             }}
           >
             {t('编辑')}
@@ -306,7 +304,10 @@ export default function OAuth2ClientSettings() {
             </Button>
             <Button
               type='primary'
-              onClick={() => setShowCreateModal(true)}
+              onClick={() => {
+                setEditingClient(null);
+                setShowModal(true);
+              }}
               size='small'
             >
               {t('创建客户端')}
@@ -348,33 +349,29 @@ export default function OAuth2ClientSettings() {
             )}
             style={{ padding: 30 }}
           >
-            <Button type='primary' onClick={() => setShowCreateModal(true)}>
+            <Button
+              type='primary'
+              onClick={() => {
+                setEditingClient(null);
+                setShowModal(true);
+              }}
+            >
               {t('创建第一个客户端')}
             </Button>
           </Empty>
         }
       />
 
-      {/* 创建客户端模态框 */}
-      <CreateOAuth2ClientModal
-        visible={showCreateModal}
-        onCancel={() => setShowCreateModal(false)}
-        onSuccess={() => {
-          setShowCreateModal(false);
-          loadClients();
-        }}
-      />
-
-      {/* 编辑客户端模态框 */}
-      <EditOAuth2ClientModal
-        visible={showEditModal}
+      {/* OAuth2 客户端模态框 */}
+      <OAuth2ClientModal
+        visible={showModal}
         client={editingClient}
         onCancel={() => {
-          setShowEditModal(false);
+          setShowModal(false);
           setEditingClient(null);
         }}
         onSuccess={() => {
-          setShowEditModal(false);
+          setShowModal(false);
           setEditingClient(null);
           loadClients();
         }}

+ 78 - 0
web/src/components/settings/oauth2/modals/ClientInfoModal.jsx

@@ -0,0 +1,78 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React from 'react';
+import { Modal, Banner, Typography } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+
+const { Text } = Typography;
+
+const ClientInfoModal = ({ visible, onClose, clientId, clientSecret }) => {
+  const { t } = useTranslation();
+
+  return (
+    <Modal
+      title={t('客户端创建成功')}
+      visible={visible}
+      onCancel={onClose}
+      onOk={onClose}
+      cancelText=''
+      okText={t('我已复制保存')}
+      width={650}
+      bodyStyle={{ padding: '20px 24px' }}
+    >
+      <Banner
+        type='success'
+        closeIcon={null}
+        description={t(
+          '客户端信息如下,请立即复制保存。关闭此窗口后将无法再次查看密钥。',
+        )}
+        className='mb-5 !rounded-lg'
+      />
+
+      <div className='space-y-4'>
+        <div className='flex justify-center items-center'>
+          <div className='text-center'>
+            <Text strong className='block mb-2'>
+              {t('客户端ID')}
+            </Text>
+            <Text code copyable>
+              {clientId}
+            </Text>
+          </div>
+        </div>
+
+        {clientSecret && (
+          <div className='flex justify-center items-center'>
+            <div className='text-center'>
+              <Text strong className='block mb-2'>
+                {t('客户端密钥(仅此一次显示)')}
+              </Text>
+              <Text code copyable>
+                {clientSecret}
+              </Text>
+            </div>
+          </div>
+        )}
+      </div>
+    </Modal>
+  );
+};
+
+export default ClientInfoModal;

+ 0 - 528
web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx

@@ -1,528 +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 {
-  Modal,
-  Form,
-  Input,
-  Select,
-  Switch,
-  Space,
-  Typography,
-  Divider,
-  Button,
-  Row,
-  Col,
-} from '@douyinfe/semi-ui';
-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([]);
-  const [clientType, setClientType] = useState('confidential');
-  const [grantTypes, setGrantTypes] = useState(['client_credentials']);
-  const [allowedGrantTypes, setAllowedGrantTypes] = useState([
-    'client_credentials',
-    'authorization_code',
-    'refresh_token',
-  ]);
-
-  // 加载后端允许的授权类型(用于限制和默认值)
-  useEffect(() => {
-    let mounted = true;
-    (async () => {
-      try {
-        const res = await API.get('/api/option/');
-        const { success, data } = res.data || {};
-        if (!success || !Array.isArray(data)) return;
-        const found = data.find((i) => i.key === 'oauth2.allowed_grant_types');
-        if (!found) return;
-        let parsed = [];
-        try {
-          parsed = JSON.parse(found.value || '[]');
-        } catch (_) {}
-        if (mounted && Array.isArray(parsed) && parsed.length) {
-          setAllowedGrantTypes(parsed);
-        }
-      } catch (_) {
-        // 忽略错误,使用默认allowedGrantTypes
-      }
-    })();
-    return () => {
-      mounted = false;
-    };
-  }, []);
-
-  const computeDefaultGrantTypes = (type, allowed) => {
-    const cand =
-      type === 'public'
-        ? ['authorization_code', 'refresh_token']
-        : ['client_credentials', 'authorization_code', 'refresh_token'];
-    const subset = cand.filter((g) => allowed.includes(g));
-    return subset.length ? subset : [allowed[0]].filter(Boolean);
-  };
-
-  // 当允许的类型或客户端类型变化时,自动设置更合理的默认值
-  useEffect(() => {
-    setGrantTypes((prev) => {
-      const normalizedPrev = Array.isArray(prev) ? prev : [];
-      // 移除不被允许或与客户端类型冲突的类型
-      let next = normalizedPrev.filter((g) => allowedGrantTypes.includes(g));
-      if (clientType === 'public') {
-        next = next.filter((g) => g !== 'client_credentials');
-      }
-      // 如果为空,则使用计算的默认
-      if (!next.length) {
-        next = computeDefaultGrantTypes(clientType, allowedGrantTypes);
-      }
-      return next;
-    });
-  }, [clientType, allowedGrantTypes]);
-
-  const isGrantTypeDisabled = (value) => {
-    if (!allowedGrantTypes.includes(value)) return true;
-    if (clientType === 'public' && value === 'client_credentials') return true;
-    return false;
-  };
-
-  // URL校验:允许 http(s),本地开发可 http
-  const isValidRedirectUri = (uri) => {
-    if (!uri || !uri.trim()) return false;
-    try {
-      const u = new URL(uri.trim());
-      if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
-      if (u.protocol === 'http:') {
-        // 仅允许本地开发时使用 http
-        const host = u.hostname;
-        const isLocal =
-          host === 'localhost' ||
-          host === '127.0.0.1' ||
-          host.endsWith('.local');
-        if (!isLocal) return false;
-      }
-      return true;
-    } catch (e) {
-      return false;
-    }
-  };
-
-  // 处理提交
-  const handleSubmit = async (values) => {
-    setLoading(true);
-    try {
-      // 过滤空的重定向URI
-      const validRedirectUris = redirectUris
-        .map((u) => (u || '').trim())
-        .filter((u) => u.length > 0);
-
-      // 业务校验
-      if (!grantTypes.length) {
-        showError(t('请至少选择一种授权类型'));
-        return;
-      }
-      // 校验是否包含不被允许的授权类型
-      const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g));
-      if (invalids.length) {
-        showError(
-          t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') }),
-        );
-        return;
-      }
-      if (
-        clientType === 'public' &&
-        grantTypes.includes('client_credentials')
-      ) {
-        showError(t('公开客户端不允许使用client_credentials授权类型'));
-        return;
-      }
-      if (grantTypes.includes('authorization_code')) {
-        if (!validRedirectUris.length) {
-          showError(t('选择授权码授权类型时,必须填写至少一个重定向URI'));
-          return;
-        }
-        const allValid = validRedirectUris.every(isValidRedirectUri);
-        if (!allValid) {
-          showError(t('重定向URI格式不合法:仅支持https,或本地开发使用http'));
-          return;
-        }
-      }
-
-      const payload = {
-        ...values,
-        client_type: clientType,
-        grant_types: grantTypes,
-        redirect_uris: validRedirectUris,
-      };
-
-      const res = await API.post('/api/oauth_clients/', payload);
-      const { success, message, client_id, client_secret } = res.data;
-
-      if (success) {
-        showSuccess(t('OAuth2客户端创建成功'));
-
-        // 显示客户端信息
-        Modal.info({
-          title: t('客户端创建成功'),
-          content: (
-            <div>
-              <Paragraph>{t('请妥善保存以下信息:')}</Paragraph>
-              <div
-                style={{
-                  background: '#f8f9fa',
-                  padding: '16px',
-                  borderRadius: '6px',
-                }}
-              >
-                <div style={{ marginBottom: '12px' }}>
-                  <Text strong>{t('客户端ID')}:</Text>
-                  <br />
-                  <Text code copyable style={{ fontFamily: 'monospace' }}>
-                    {client_id}
-                  </Text>
-                </div>
-                {client_secret && (
-                  <div>
-                    <Text strong>{t('客户端密钥(仅此一次显示)')}:</Text>
-                    <br />
-                    <Text code copyable style={{ fontFamily: 'monospace' }}>
-                      {client_secret}
-                    </Text>
-                  </div>
-                )}
-              </div>
-              <Paragraph type='warning' style={{ marginTop: '12px' }}>
-                {client_secret
-                  ? t('客户端密钥仅显示一次,请立即复制保存。')
-                  : t('公开客户端无需密钥。')}
-              </Paragraph>
-            </div>
-          ),
-          width: 600,
-          onOk: () => {
-            resetForm();
-            onSuccess();
-          },
-        });
-      } else {
-        showError(message);
-      }
-    } catch (error) {
-      showError(t('创建OAuth2客户端失败'));
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  // 重置表单
-  const resetForm = () => {
-    if (formApi) {
-      formApi.reset();
-    }
-    setClientType('confidential');
-    setGrantTypes(computeDefaultGrantTypes('confidential', allowedGrantTypes));
-    setRedirectUris([]);
-  };
-
-  // 处理取消
-  const handleCancel = () => {
-    resetForm();
-    onCancel();
-  };
-
-  // 添加重定向URI
-  const addRedirectUri = () => {
-    setRedirectUris([...redirectUris, '']);
-  };
-
-  // 删除重定向URI
-  const removeRedirectUri = (index) => {
-    setRedirectUris(redirectUris.filter((_, i) => i !== index));
-  };
-
-  // 更新重定向URI
-  const updateRedirectUri = (index, value) => {
-    const newUris = [...redirectUris];
-    newUris[index] = value;
-    setRedirectUris(newUris);
-  };
-
-  // 授权类型变化处理
-  const handleGrantTypesChange = (values) => {
-    setGrantTypes(values);
-    // 如果包含authorization_code但没有重定向URI,则添加一个
-    if (values.includes('authorization_code') && redirectUris.length === 0) {
-      setRedirectUris(['']);
-    }
-    // 公开客户端不允许client_credentials
-    if (clientType === 'public' && values.includes('client_credentials')) {
-      setGrantTypes(values.filter((v) => v !== 'client_credentials'));
-    }
-  };
-
-  return (
-    <Modal
-      title={t('创建OAuth2客户端')}
-      visible={visible}
-      onCancel={handleCancel}
-      onOk={() => formApi?.submitForm()}
-      okText={t('创建')}
-      cancelText={t('取消')}
-      confirmLoading={loading}
-      width='90vw'
-      style={{
-        top: 20,
-        maxWidth: '800px',
-        '@media (min-width: 768px)': {
-          width: '600px',
-        },
-      }}
-    >
-      <Form
-        getFormApi={(api) => setFormApi(api)}
-        initValues={{
-          // 表单默认值优化:预置 OIDC 常用 scope
-          scopes: ['openid', 'profile', 'email', 'api:read'],
-          require_pkce: true,
-          grant_types: grantTypes,
-        }}
-        onSubmit={handleSubmit}
-        labelPosition='top'
-      >
-        {/* 基本信息 */}
-        <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>{t('客户端类型')}</Text>
-          <Paragraph
-            type='tertiary'
-            size='small'
-            style={{ marginTop: 4, marginBottom: 8 }}
-          >
-            {t('选择适合您应用程序的客户端类型。')}
-          </Paragraph>
-          <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>
-
-        <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 */}
-          <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设置 */}
-          <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>{t('重定向URI配置')}</Divider>
-            <div style={{ marginBottom: 16 }}>
-              <Text strong>{t('重定向URI')}</Text>
-              <Paragraph type='tertiary' size='small'>
-                {t(
-                  '用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。',
-                )}
-              </Paragraph>
-
-              <div style={{ width: '100%' }}>
-                {redirectUris.map((uri, 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'
-                          onClick={() => removeRedirectUri(index)}
-                          style={{ width: '100%' }}
-                        >
-                          {t('删除')}
-                        </Button>
-                      </Col>
-                    )}
-                  </Row>
-                ))}
-              </div>
-
-              <Button
-                theme='borderless'
-                type='primary'
-                size='small'
-                onClick={addRedirectUri}
-                style={{ marginTop: 8 }}
-              >
-                {t('添加重定向URI')}
-              </Button>
-            </div>
-          </>
-        )}
-      </Form>
-    </Modal>
-  );
-};
-
-export default CreateOAuth2ClientModal;

+ 0 - 453
web/src/components/settings/oauth2/modals/EditOAuth2ClientModal.jsx

@@ -1,453 +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, { useState, useEffect } from 'react';
-import {
-  Modal,
-  Form,
-  Input,
-  Select,
-  TextArea,
-  Switch,
-  Space,
-  Typography,
-  Divider,
-  Button,
-} from '@douyinfe/semi-ui';
-import { API, showError, showSuccess } from '../../../../helpers';
-import { useTranslation } from 'react-i18next';
-
-const { Text, Paragraph } = Typography;
-const { Option } = Select;
-
-const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
-  const { t } = useTranslation();
-  const [formApi, setFormApi] = useState(null);
-  const [loading, setLoading] = useState(false);
-  const [redirectUris, setRedirectUris] = useState([]);
-  const [grantTypes, setGrantTypes] = useState(['client_credentials']);
-  const [allowedGrantTypes, setAllowedGrantTypes] = useState([
-    'client_credentials',
-    'authorization_code',
-    'refresh_token',
-  ]);
-
-  // 加载后端允许的授权类型
-  useEffect(() => {
-    let mounted = true;
-    (async () => {
-      try {
-        const res = await API.get('/api/option/');
-        const { success, data } = res.data || {};
-        if (!success || !Array.isArray(data)) return;
-        const found = data.find((i) => i.key === 'oauth2.allowed_grant_types');
-        if (!found) return;
-        let parsed = [];
-        try {
-          parsed = JSON.parse(found.value || '[]');
-        } catch (_) {}
-        if (mounted && Array.isArray(parsed) && parsed.length) {
-          setAllowedGrantTypes(parsed);
-        }
-      } catch (_) {
-        // 忽略错误
-      }
-    })();
-    return () => {
-      mounted = false;
-    };
-  }, []);
-
-  // 初始化表单数据
-  useEffect(() => {
-    if (client && visible) {
-      // 解析授权类型
-      let parsedGrantTypes = [];
-      if (typeof client.grant_types === 'string') {
-        parsedGrantTypes = client.grant_types.split(',');
-      } else if (Array.isArray(client.grant_types)) {
-        parsedGrantTypes = client.grant_types;
-      }
-
-      // 解析Scope
-      let parsedScopes = [];
-      if (typeof client.scopes === 'string') {
-        parsedScopes = client.scopes.split(',');
-      } else if (Array.isArray(client.scopes)) {
-        parsedScopes = client.scopes;
-      }
-      if (!parsedScopes || parsedScopes.length === 0) {
-        parsedScopes = ['openid', 'profile', 'email', 'api:read'];
-      }
-
-      // 解析重定向URI
-      let parsedRedirectUris = [];
-      if (client.redirect_uris) {
-        try {
-          const parsed =
-            typeof client.redirect_uris === 'string'
-              ? JSON.parse(client.redirect_uris)
-              : client.redirect_uris;
-          if (Array.isArray(parsed) && parsed.length > 0) {
-            parsedRedirectUris = parsed;
-          }
-        } catch (e) {
-          console.warn('Failed to parse redirect URIs:', e);
-        }
-      }
-
-      // 过滤不被允许或不兼容的授权类型
-      const filteredGrantTypes = (parsedGrantTypes || []).filter((g) =>
-        allowedGrantTypes.includes(g),
-      );
-      const finalGrantTypes =
-        client.client_type === 'public'
-          ? filteredGrantTypes.filter((g) => g !== 'client_credentials')
-          : filteredGrantTypes;
-
-      setGrantTypes(finalGrantTypes);
-      if (
-        finalGrantTypes.includes('authorization_code') &&
-        parsedRedirectUris.length === 0
-      ) {
-        setRedirectUris(['']);
-      } else {
-        setRedirectUris(parsedRedirectUris);
-      }
-
-      // 设置表单值
-      const formValues = {
-        id: client.id,
-        name: client.name,
-        description: client.description,
-        client_type: client.client_type,
-        grant_types: parsedGrantTypes,
-        scopes: parsedScopes,
-        require_pkce: !!client.require_pkce,
-        status: client.status,
-      };
-      if (formApi) {
-        formApi.setValues(formValues);
-      }
-    }
-  }, [client, visible, formApi]);
-
-  // 处理提交
-  const handleSubmit = async (values) => {
-    setLoading(true);
-    try {
-      // 过滤空的重定向URI
-      const validRedirectUris = redirectUris
-        .map((u) => (u || '').trim())
-        .filter((u) => u.length > 0);
-
-      // 校验授权类型
-      if (!grantTypes.length) {
-        showError(t('请至少选择一种授权类型'));
-        setLoading(false);
-        return;
-      }
-      const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g));
-      if (invalids.length) {
-        showError(
-          t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') }),
-        );
-        setLoading(false);
-        return;
-      }
-      if (
-        client?.client_type === 'public' &&
-        grantTypes.includes('client_credentials')
-      ) {
-        showError(t('公开客户端不允许使用client_credentials授权类型'));
-        setLoading(false);
-        return;
-      }
-      // 授权码需要有效重定向URI
-      const isValidRedirectUri = (uri) => {
-        if (!uri || !uri.trim()) return false;
-        try {
-          const u = new URL(uri.trim());
-          if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
-          if (u.protocol === 'http:') {
-            const host = u.hostname;
-            const isLocal =
-              host === 'localhost' ||
-              host === '127.0.0.1' ||
-              host.endsWith('.local');
-            if (!isLocal) return false;
-          }
-          return true;
-        } catch (e) {
-          return false;
-        }
-      };
-      if (grantTypes.includes('authorization_code')) {
-        if (!validRedirectUris.length) {
-          showError(t('选择授权码授权类型时,必须填写至少一个重定向URI'));
-          setLoading(false);
-          return;
-        }
-        const allValid = validRedirectUris.every(isValidRedirectUri);
-        if (!allValid) {
-          showError(t('重定向URI格式不合法:仅支持https,或本地开发使用http'));
-          setLoading(false);
-          return;
-        }
-      }
-
-      const payload = {
-        ...values,
-        grant_types: grantTypes,
-        redirect_uris: validRedirectUris,
-      };
-
-      const res = await API.put('/api/oauth_clients/', payload);
-      const { success, message } = res.data;
-
-      if (success) {
-        showSuccess(t('OAuth2客户端更新成功'));
-        onSuccess();
-      } else {
-        showError(message);
-      }
-    } catch (error) {
-      showError(t('更新OAuth2客户端失败'));
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  // 添加重定向URI
-  const addRedirectUri = () => {
-    setRedirectUris([...redirectUris, '']);
-  };
-
-  // 删除重定向URI
-  const removeRedirectUri = (index) => {
-    setRedirectUris(redirectUris.filter((_, i) => i !== index));
-  };
-
-  // 更新重定向URI
-  const updateRedirectUri = (index, value) => {
-    const newUris = [...redirectUris];
-    newUris[index] = value;
-    setRedirectUris(newUris);
-  };
-
-  // 授权类型变化处理
-  const handleGrantTypesChange = (values) => {
-    setGrantTypes(values);
-    // 如果包含authorization_code但没有重定向URI,则添加一个
-    if (values.includes('authorization_code') && redirectUris.length === 0) {
-      setRedirectUris(['']);
-    }
-    // 公开客户端不允许client_credentials
-    if (
-      client?.client_type === 'public' &&
-      values.includes('client_credentials')
-    ) {
-      setGrantTypes(values.filter((v) => v !== 'client_credentials'));
-    }
-  };
-
-  if (!client) return null;
-
-  return (
-    <Modal
-      title={t('编辑OAuth2客户端 - {{name}}', { name: client.name })}
-      visible={visible}
-      onCancel={onCancel}
-      onOk={() => formApi?.submitForm()}
-      okText={t('保存')}
-      cancelText={t('取消')}
-      confirmLoading={loading}
-      width={600}
-      style={{ top: 50 }}
-    >
-      <Form
-        getFormApi={(api) => setFormApi(api)}
-        onSubmit={handleSubmit}
-        labelPosition='top'
-      >
-        {/* 客户端ID(只读) */}
-        <Form.Input
-          field='id'
-          label={t('客户端ID')}
-          disabled
-          style={{ backgroundColor: '#f8f9fa' }}
-        />
-
-        {/* 基本信息 */}
-        <Form.Input
-          field='name'
-          label={t('客户端名称')}
-          placeholder={t('输入客户端名称')}
-          rules={[{ required: true, message: t('请输入客户端名称') }]}
-        />
-
-        <Form.TextArea
-          field='description'
-          label={t('描述')}
-          placeholder={t('输入客户端描述')}
-          rows={3}
-        />
-
-        {/* 客户端类型(只读) */}
-        <Form.Select
-          field='client_type'
-          label={t('客户端类型')}
-          disabled
-          style={{ backgroundColor: '#f8f9fa' }}
-        >
-          <Option value='confidential'>
-            {t('机密客户端(Confidential)')}
-          </Option>
-          <Option value='public'>{t('公开客户端(Public)')}</Option>
-        </Form.Select>
-
-        <Paragraph
-          type='tertiary'
-          size='small'
-          style={{ marginTop: -8, marginBottom: 16 }}
-        >
-          {t('客户端类型创建后不可更改。')}
-        </Paragraph>
-
-        {/* 授权类型 */}
-        <Form.Select
-          field='grant_types'
-          label={t('允许的授权类型')}
-          multiple
-          value={grantTypes}
-          onChange={handleGrantTypesChange}
-          rules={[{ required: true, message: t('请选择至少一种授权类型') }]}
-        >
-          <Option
-            value='client_credentials'
-            disabled={
-              client?.client_type === 'public' ||
-              !allowedGrantTypes.includes('client_credentials')
-            }
-          >
-            {t('Client Credentials(客户端凭证)')}
-          </Option>
-          <Option
-            value='authorization_code'
-            disabled={!allowedGrantTypes.includes('authorization_code')}
-          >
-            {t('Authorization Code(授权码)')}
-          </Option>
-          <Option
-            value='refresh_token'
-            disabled={!allowedGrantTypes.includes('refresh_token')}
-          >
-            {t('Refresh Token(刷新令牌)')}
-          </Option>
-        </Form.Select>
-
-        {/* Scope */}
-        <Form.Select
-          field='scopes'
-          label={t('允许的权限范围(Scope)')}
-          multiple
-          rules={[{ required: true, message: t('请选择至少一个权限范围') }]}
-        >
-          <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>
-
-        {/* PKCE设置 */}
-        <Form.Switch field='require_pkce' label={t('强制PKCE验证')} />
-        <Paragraph
-          type='tertiary'
-          size='small'
-          style={{ marginTop: -8, marginBottom: 16 }}
-        >
-          {t('PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。')}
-        </Paragraph>
-
-        {/* 状态 */}
-        <Form.Select
-          field='status'
-          label={t('状态')}
-          rules={[{ required: true, message: t('请选择状态') }]}
-        >
-          <Option value={1}>{t('启用')}</Option>
-          <Option value={2}>{t('禁用')}</Option>
-        </Form.Select>
-
-        {/* 重定向URI */}
-        {(grantTypes.includes('authorization_code') ||
-          redirectUris.length > 0) && (
-          <>
-            <Divider>{t('重定向URI配置')}</Divider>
-            <div style={{ marginBottom: 16 }}>
-              <Text strong>{t('重定向URI')}</Text>
-              <Paragraph type='tertiary' size='small'>
-                {t(
-                  '用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。',
-                )}
-              </Paragraph>
-
-              <Space direction='vertical' 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'
-                        onClick={() => removeRedirectUri(index)}
-                      >
-                        {t('删除')}
-                      </Button>
-                    )}
-                  </Space>
-                ))}
-              </Space>
-
-              <Button
-                theme='borderless'
-                type='primary'
-                size='small'
-                onClick={addRedirectUri}
-                style={{ marginTop: 8 }}
-              >
-                {t('添加重定向URI')}
-              </Button>
-            </div>
-          </>
-        )}
-      </Form>
-    </Modal>
-  );
-};
-
-export default EditOAuth2ClientModal;

+ 730 - 0
web/src/components/settings/oauth2/modals/OAuth2ClientModal.jsx

@@ -0,0 +1,730 @@
+/*
+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 {
+  SideSheet,
+  Form,
+  Input,
+  Select,
+  Space,
+  Typography,
+  Button,
+  Card,
+  Avatar,
+  Tag,
+  Spin,
+  Radio,
+  Divider,
+} from '@douyinfe/semi-ui';
+import {
+  IconKey,
+  IconLink,
+  IconSave,
+  IconClose,
+  IconPlus,
+  IconDelete,
+} from '@douyinfe/semi-icons';
+import { API, showError, showSuccess } from '../../../../helpers';
+import { useTranslation } from 'react-i18next';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+import ClientInfoModal from './ClientInfoModal';
+
+const { Text, Title } = Typography;
+const { Option } = Select;
+
+const AUTH_CODE = 'authorization_code';
+const CLIENT_CREDENTIALS = 'client_credentials';
+
+// 子组件:重定向URI编辑卡片
+function RedirectUriCard({
+  t,
+  isAuthCodeSelected,
+  redirectUris,
+  onAdd,
+  onUpdate,
+  onRemove,
+  onFillTemplate,
+}) {
+  return (
+    <Card
+      header={
+        <div className='flex justify-between items-center'>
+          <div className='flex items-center'>
+            <Avatar size='small' color='purple' className='mr-2 shadow-md'>
+              <IconLink size={16} />
+            </Avatar>
+            <div>
+              <Text className='text-lg font-medium'>{t('重定向URI配置')}</Text>
+              <div className='text-xs text-gray-600'>
+                {t('用于授权码流程的重定向地址')}
+              </div>
+            </div>
+          </div>
+          <Button
+            type='tertiary'
+            onClick={onFillTemplate}
+            size='small'
+            disabled={!isAuthCodeSelected}
+          >
+            {t('填入示例模板')}
+          </Button>
+        </div>
+      }
+      headerStyle={{ padding: '12px 16px' }}
+      bodyStyle={{ padding: '16px' }}
+      className='!rounded-2xl shadow-sm border-0'
+    >
+      <div className='space-y-1'>
+        {redirectUris.length === 0 && (
+          <div className='text-center py-4 px-4'>
+            <Text type='tertiary' className='text-gray-500 text-sm'>
+              {t('暂无重定向URI,点击下方按钮添加')}
+            </Text>
+          </div>
+        )}
+
+        {redirectUris.map((uri, index) => (
+          <div
+            key={index}
+            style={{
+              marginBottom: 8,
+              display: 'flex',
+              gap: 8,
+              alignItems: 'center',
+            }}
+          >
+            <Input
+              placeholder={t('例如:https://your-app.com/callback')}
+              value={uri}
+              onChange={(value) => onUpdate(index, value)}
+              style={{ flex: 1 }}
+              disabled={!isAuthCodeSelected}
+            />
+            <Button
+              icon={<IconDelete />}
+              type='danger'
+              theme='borderless'
+              onClick={() => onRemove(index)}
+              disabled={!isAuthCodeSelected}
+            />
+          </div>
+        ))}
+
+        <div className='py-2 flex justify-center gap-2'>
+          <Button
+            icon={<IconPlus />}
+            type='primary'
+            theme='outline'
+            onClick={onAdd}
+            disabled={!isAuthCodeSelected}
+          >
+            {t('添加重定向URI')}
+          </Button>
+        </div>
+      </div>
+
+      <Divider margin='12px' align='center'>
+        <Text type='tertiary' size='small'>
+          {isAuthCodeSelected
+            ? t(
+                '用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)',
+              )
+            : t('仅在选择“授权码”授权类型时需要配置重定向URI')}
+        </Text>
+      </Divider>
+    </Card>
+  );
+}
+
+const OAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
+  const { t } = useTranslation();
+  const isMobile = useIsMobile();
+  const formApiRef = useRef(null);
+  const [loading, setLoading] = useState(false);
+  const [redirectUris, setRedirectUris] = useState([]);
+  const [clientType, setClientType] = useState('confidential');
+  const [grantTypes, setGrantTypes] = useState([]);
+  const [allowedGrantTypes, setAllowedGrantTypes] = useState([
+    CLIENT_CREDENTIALS,
+    AUTH_CODE,
+    'refresh_token',
+  ]);
+
+  // ClientInfoModal 状态
+  const [showClientInfo, setShowClientInfo] = useState(false);
+  const [clientInfo, setClientInfo] = useState({
+    clientId: '',
+    clientSecret: '',
+  });
+
+  const isEdit = client?.id !== undefined;
+  const [mode, setMode] = useState('create'); // 'create' | 'edit'
+  useEffect(() => {
+    if (visible) {
+      setMode(isEdit ? 'edit' : 'create');
+    }
+  }, [visible, isEdit]);
+
+  const getInitValues = () => ({
+    name: '',
+    description: '',
+    client_type: 'confidential',
+    grant_types: [],
+    scopes: [],
+    require_pkce: true,
+    status: 1,
+  });
+
+  // 加载后端允许的授权类型
+  useEffect(() => {
+    let mounted = true;
+    (async () => {
+      try {
+        const res = await API.get('/api/option/');
+        const { success, data } = res.data || {};
+        if (!success || !Array.isArray(data)) return;
+        const found = data.find((i) => i.key === 'oauth2.allowed_grant_types');
+        if (!found) return;
+        let parsed = [];
+        try {
+          parsed = JSON.parse(found.value || '[]');
+        } catch (_) {}
+        if (mounted && Array.isArray(parsed) && parsed.length) {
+          setAllowedGrantTypes(parsed);
+        }
+      } catch (_) {
+        // 忽略错误,使用默认allowedGrantTypes
+      }
+    })();
+    return () => {
+      mounted = false;
+    };
+  }, []);
+
+  useEffect(() => {
+    setGrantTypes((prev) => {
+      const normalizedPrev = Array.isArray(prev) ? prev : [];
+      // 移除不被允许或与客户端类型冲突的类型
+      let next = normalizedPrev.filter((g) => allowedGrantTypes.includes(g));
+      if (clientType === 'public') {
+        next = next.filter((g) => g !== CLIENT_CREDENTIALS);
+      }
+      return next.length ? next : [];
+    });
+  }, [clientType, allowedGrantTypes]);
+
+  // 初始化表单数据(编辑模式)
+  useEffect(() => {
+    if (client && visible && isEdit) {
+      setLoading(true);
+      // 解析授权类型
+      let parsedGrantTypes = [];
+      if (typeof client.grant_types === 'string') {
+        parsedGrantTypes = client.grant_types.split(',');
+      } else if (Array.isArray(client.grant_types)) {
+        parsedGrantTypes = client.grant_types;
+      }
+
+      // 解析Scope
+      let parsedScopes = [];
+      if (typeof client.scopes === 'string') {
+        parsedScopes = client.scopes.split(',');
+      } else if (Array.isArray(client.scopes)) {
+        parsedScopes = client.scopes;
+      }
+      if (!parsedScopes || parsedScopes.length === 0) {
+        parsedScopes = ['openid', 'profile', 'email', 'api:read'];
+      }
+
+      // 解析重定向URI
+      let parsedRedirectUris = [];
+      if (client.redirect_uris) {
+        try {
+          const parsed =
+            typeof client.redirect_uris === 'string'
+              ? JSON.parse(client.redirect_uris)
+              : client.redirect_uris;
+          if (Array.isArray(parsed) && parsed.length > 0) {
+            parsedRedirectUris = parsed;
+          }
+        } catch (e) {}
+      }
+
+      // 过滤不被允许或不兼容的授权类型
+      const filteredGrantTypes = (parsedGrantTypes || []).filter((g) =>
+        allowedGrantTypes.includes(g),
+      );
+      const finalGrantTypes =
+        client.client_type === 'public'
+          ? filteredGrantTypes.filter((g) => g !== CLIENT_CREDENTIALS)
+          : filteredGrantTypes;
+
+      setClientType(client.client_type);
+      setGrantTypes(finalGrantTypes);
+      // 不自动新增空白URI,保持与创建模式一致的手动添加体验
+      setRedirectUris(parsedRedirectUris);
+
+      // 设置表单值
+      const formValues = {
+        id: client.id,
+        name: client.name,
+        description: client.description,
+        client_type: client.client_type,
+        grant_types: finalGrantTypes,
+        scopes: parsedScopes,
+        require_pkce: !!client.require_pkce,
+        status: client.status,
+      };
+
+      setTimeout(() => {
+        if (formApiRef.current) {
+          formApiRef.current.setValues(formValues);
+        }
+        setLoading(false);
+      }, 100);
+    } else if (visible && !isEdit) {
+      // 创建模式,重置状态
+      setClientType('confidential');
+      setGrantTypes([]);
+      setRedirectUris([]);
+      if (formApiRef.current) {
+        formApiRef.current.setValues(getInitValues());
+      }
+    }
+  }, [client, visible, isEdit, allowedGrantTypes]);
+
+  const isAuthCodeSelected = grantTypes.includes(AUTH_CODE);
+  const isGrantTypeDisabled = (value) => {
+    if (!allowedGrantTypes.includes(value)) return true;
+    if (clientType === 'public' && value === CLIENT_CREDENTIALS) return true;
+    return false;
+  };
+
+  // URL校验:允许 https;http 仅限本地开发域名
+  const isValidRedirectUri = (uri) => {
+    if (!uri || !uri.trim()) return false;
+    try {
+      const u = new URL(uri.trim());
+      if (u.protocol === 'https:') return true;
+      if (u.protocol === 'http:') {
+        const host = u.hostname;
+        return (
+          host === 'localhost' ||
+          host === '127.0.0.1' ||
+          host.endsWith('.local')
+        );
+      }
+      return false;
+    } catch (_) {
+      return false;
+    }
+  };
+
+  // 处理提交
+  const handleSubmit = async (values) => {
+    setLoading(true);
+    try {
+      // 过滤空的重定向URI
+      const validRedirectUris = redirectUris
+        .map((u) => (u || '').trim())
+        .filter((u) => u.length > 0);
+
+      // 业务校验
+      if (!grantTypes.length) {
+        showError(t('请至少选择一种授权类型'));
+        setLoading(false);
+        return;
+      }
+
+      // 校验是否包含不被允许的授权类型
+      const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g));
+      if (invalids.length) {
+        showError(
+          t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') }),
+        );
+        setLoading(false);
+        return;
+      }
+
+      if (clientType === 'public' && grantTypes.includes(CLIENT_CREDENTIALS)) {
+        showError(t('公开客户端不允许使用client_credentials授权类型'));
+        setLoading(false);
+        return;
+      }
+
+      if (grantTypes.includes(AUTH_CODE)) {
+        if (!validRedirectUris.length) {
+          showError(t('选择授权码授权类型时,必须填写至少一个重定向URI'));
+          setLoading(false);
+          return;
+        }
+        const allValid = validRedirectUris.every(isValidRedirectUri);
+        if (!allValid) {
+          showError(t('重定向URI格式不合法:仅支持https,或本地开发使用http'));
+          setLoading(false);
+          return;
+        }
+      }
+
+      // 避免把 Radio 组件对象形式的 client_type 直接传给后端
+      const { client_type: _formClientType, ...restValues } = values || {};
+      const payload = {
+        ...restValues,
+        client_type: clientType,
+        grant_types: grantTypes,
+        redirect_uris: validRedirectUris,
+      };
+
+      let res;
+      if (isEdit) {
+        res = await API.put('/api/oauth_clients/', payload);
+      } else {
+        res = await API.post('/api/oauth_clients/', payload);
+      }
+
+      const { success, message, client_id, client_secret } = res.data;
+
+      if (success) {
+        if (isEdit) {
+          showSuccess(t('OAuth2客户端更新成功'));
+          resetForm();
+          onSuccess();
+        } else {
+          showSuccess(t('OAuth2客户端创建成功'));
+          // 显示客户端信息
+          setClientInfo({
+            clientId: client_id,
+            clientSecret: client_secret,
+          });
+          setShowClientInfo(true);
+        }
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      showError(isEdit ? t('更新OAuth2客户端失败') : t('创建OAuth2客户端失败'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 重置表单
+  const resetForm = () => {
+    if (formApiRef.current) {
+      formApiRef.current.reset();
+    }
+    setClientType('confidential');
+    setGrantTypes([]);
+    setRedirectUris([]);
+  };
+
+  // 处理ClientInfoModal关闭
+  const handleClientInfoClose = () => {
+    setShowClientInfo(false);
+    setClientInfo({ clientId: '', clientSecret: '' });
+    resetForm();
+    onSuccess();
+  };
+
+  // 处理取消
+  const handleCancel = () => {
+    resetForm();
+    onCancel();
+  };
+
+  // 添加重定向URI
+  const addRedirectUri = () => {
+    setRedirectUris([...redirectUris, '']);
+  };
+
+  // 删除重定向URI
+  const removeRedirectUri = (index) => {
+    setRedirectUris(redirectUris.filter((_, i) => i !== index));
+  };
+
+  // 更新重定向URI
+  const updateRedirectUri = (index, value) => {
+    const newUris = [...redirectUris];
+    newUris[index] = value;
+    setRedirectUris(newUris);
+  };
+
+  // 填入示例重定向URI模板
+  const fillRedirectUriTemplate = () => {
+    const template = [
+      'https://your-app.com/auth/callback',
+      'https://localhost:3000/callback',
+    ];
+    setRedirectUris(template);
+  };
+
+  // 授权类型变化处理(清理非法项,只设置一次)
+  const handleGrantTypesChange = (values) => {
+    const allowed = Array.isArray(values)
+      ? values.filter((v) => allowedGrantTypes.includes(v))
+      : [];
+    const sanitized =
+      clientType === 'public'
+        ? allowed.filter((v) => v !== CLIENT_CREDENTIALS)
+        : allowed;
+    setGrantTypes(sanitized);
+    if (formApiRef.current) {
+      formApiRef.current.setValue('grant_types', sanitized);
+    }
+  };
+
+  // 客户端类型变化处理(兼容 RadioGroup 事件对象与直接值)
+  const handleClientTypeChange = (next) => {
+    const value = next && next.target ? next.target.value : next;
+    setClientType(value);
+    // 公开客户端自动移除 client_credentials,并同步表单字段
+    const current = Array.isArray(grantTypes) ? grantTypes : [];
+    const sanitized =
+      value === 'public'
+        ? current.filter((g) => g !== CLIENT_CREDENTIALS)
+        : current;
+    if (sanitized !== current) {
+      setGrantTypes(sanitized);
+      if (formApiRef.current) {
+        formApiRef.current.setValue('grant_types', sanitized);
+      }
+    }
+  };
+
+  return (
+    <SideSheet
+      placement={mode === 'edit' ? 'right' : 'left'}
+      title={
+        <Space>
+          {mode === 'edit' ? (
+            <Tag color='blue' shape='circle'>
+              {t('编辑')}
+            </Tag>
+          ) : (
+            <Tag color='green' shape='circle'>
+              {t('创建')}
+            </Tag>
+          )}
+          <Title heading={4} className='m-0'>
+            {mode === 'edit' ? t('编辑OAuth2客户端') : t('创建OAuth2客户端')}
+          </Title>
+        </Space>
+      }
+      bodyStyle={{ padding: '0' }}
+      visible={visible}
+      width={isMobile ? '100%' : 700}
+      footer={
+        <div className='flex justify-end bg-white'>
+          <Space>
+            <Button
+              theme='solid'
+              className='!rounded-lg'
+              onClick={() => formApiRef.current?.submitForm()}
+              icon={<IconSave />}
+              loading={loading}
+            >
+              {isEdit ? t('保存') : t('创建')}
+            </Button>
+            <Button
+              theme='light'
+              className='!rounded-lg'
+              type='primary'
+              onClick={handleCancel}
+              icon={<IconClose />}
+            >
+              {t('取消')}
+            </Button>
+          </Space>
+        </div>
+      }
+      closeIcon={null}
+      onCancel={handleCancel}
+    >
+      <Spin spinning={loading}>
+        <Form
+          key={isEdit ? `edit-${client?.id}` : 'create'}
+          initValues={getInitValues()}
+          getFormApi={(api) => (formApiRef.current = api)}
+          onSubmit={handleSubmit}
+        >
+          {() => (
+            <div className='p-2'>
+              {/* 表单内容 */}
+              {/* 基本信息 */}
+              <Card className='!rounded-2xl shadow-sm border-0'>
+                <div className='flex items-center mb-4'>
+                  <Avatar size='small' color='blue' className='mr-2 shadow-md'>
+                    <IconKey size={16} />
+                  </Avatar>
+                  <div>
+                    <Text className='text-lg font-medium'>{t('基本信息')}</Text>
+                    <div className='text-xs text-gray-600'>
+                      {t('设置客户端的基本信息')}
+                    </div>
+                  </div>
+                </div>
+                {isEdit && (
+                  <>
+                    <Form.Select
+                      field='status'
+                      label={t('状态')}
+                      rules={[{ required: true, message: t('请选择状态') }]}
+                      required
+                    >
+                      <Option value={1}>{t('启用')}</Option>
+                      <Option value={2}>{t('禁用')}</Option>
+                    </Form.Select>
+                    <Form.Input field='id' label={t('客户端ID')} disabled />
+                  </>
+                )}
+                <Form.Input
+                  field='name'
+                  label={t('客户端名称')}
+                  placeholder={t('输入客户端名称')}
+                  rules={[{ required: true, message: t('请输入客户端名称') }]}
+                  required
+                  showClear
+                />
+                <Form.TextArea
+                  field='description'
+                  label={t('描述')}
+                  placeholder={t('输入客户端描述')}
+                  rows={3}
+                  showClear
+                />
+                <Form.RadioGroup
+                  label={t('客户端类型')}
+                  field='client_type'
+                  value={clientType}
+                  onChange={handleClientTypeChange}
+                  type='card'
+                  aria-label={t('选择客户端类型')}
+                  disabled={isEdit}
+                  rules={[{ required: true, message: t('请选择客户端类型') }]}
+                  required
+                >
+                  <Radio
+                    value='confidential'
+                    extra={t('服务器端应用,安全地存储客户端密钥')}
+                    style={{ width: isMobile ? '100%' : 'auto' }}
+                  >
+                    {t('机密客户端(Confidential)')}
+                  </Radio>
+                  <Radio
+                    value='public'
+                    extra={t('移动应用或单页应用,无法安全存储密钥')}
+                    style={{ width: isMobile ? '100%' : 'auto' }}
+                  >
+                    {t('公开客户端(Public)')}
+                  </Radio>
+                </Form.RadioGroup>
+                <Form.Select
+                  field='grant_types'
+                  label={t('允许的授权类型')}
+                  multiple
+                  value={grantTypes}
+                  onChange={handleGrantTypesChange}
+                  rules={[
+                    { required: true, message: t('请选择至少一种授权类型') },
+                  ]}
+                  required
+                  placeholder={t('请选择授权类型(可多选)')}
+                >
+                  {clientType !== 'public' && (
+                    <Option
+                      value={CLIENT_CREDENTIALS}
+                      disabled={isGrantTypeDisabled(CLIENT_CREDENTIALS)}
+                    >
+                      {t('Client Credentials(客户端凭证)')}
+                    </Option>
+                  )}
+                  <Option
+                    value={AUTH_CODE}
+                    disabled={isGrantTypeDisabled(AUTH_CODE)}
+                  >
+                    {t('Authorization Code(授权码)')}
+                  </Option>
+                  <Option
+                    value='refresh_token'
+                    disabled={isGrantTypeDisabled('refresh_token')}
+                  >
+                    {t('Refresh Token(刷新令牌)')}
+                  </Option>
+                </Form.Select>
+                <Form.Select
+                  field='scopes'
+                  label={t('允许的权限范围(Scope)')}
+                  multiple
+                  rules={[
+                    { required: true, message: t('请选择至少一个权限范围') },
+                  ]}
+                  required
+                  placeholder={t('请选择权限范围(可多选)')}
+                >
+                  <Option value='openid'>{t('openid(OIDC 基础身份)')}</Option>
+                  <Option value='profile'>
+                    {t('profile(用户名/昵称等)')}
+                  </Option>
+                  <Option value='email'>{t('email(邮箱信息)')}</Option>
+                  <Option value='api:read'>
+                    {`api:read (${t('读取API')})`}
+                  </Option>
+                  <Option value='api:write'>
+                    {`api:write (${t('写入API')})`}
+                  </Option>
+                  <Option value='admin'>{t('admin(管理员权限)')}</Option>
+                </Form.Select>
+                <Form.Switch
+                  field='require_pkce'
+                  label={t('强制PKCE验证')}
+                  size='large'
+                  extraText={t(
+                    'PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。',
+                  )}
+                />
+              </Card>
+
+              {/* 重定向URI */}
+              <RedirectUriCard
+                t={t}
+                isAuthCodeSelected={isAuthCodeSelected}
+                redirectUris={redirectUris}
+                onAdd={addRedirectUri}
+                onUpdate={updateRedirectUri}
+                onRemove={removeRedirectUri}
+                onFillTemplate={fillRedirectUriTemplate}
+              />
+            </div>
+          )}
+        </Form>
+      </Spin>
+
+      {/* 客户端信息展示模态框 */}
+      <ClientInfoModal
+        visible={showClientInfo}
+        onClose={handleClientInfoClose}
+        clientId={clientInfo.clientId}
+        clientSecret={clientInfo.clientSecret}
+      />
+    </SideSheet>
+  );
+};
+
+export default OAuth2ClientModal;

+ 50 - 1
web/src/i18n/locales/en.json

@@ -1012,7 +1012,7 @@
   "防失联-定期通知": "Prevent loss of contact - regular notifications",
   "订阅事件后,当事件触发时,您将会收到相应的通知": "After subscribing to the event, you will receive the corresponding notification when the event is triggered.",
   "当余额低于 ": "When the balance is lower than",
-  "保存": "save",
+  "保存": "Save",
   "计费说明": "Billing instructions",
   "高稳定性": "High stability",
   "没有账号请先": "If you don't have an account, please",
@@ -2100,6 +2100,54 @@
   "折": "% off",
   "节省": "Save",
   "OAuth2 客户端管理": "OAuth2 Clients",
+  "重定向URI配置": "Redirect URI Configuration",
+  "用于授权码流程的重定向地址": "Redirect URIs for authorization code flow",
+  "填入示例模板": "Fill Template Example",
+  "暂无重定向URI,点击下方按钮添加": "No redirect URIs yet. Click the button below to add one.",
+  "例如:https://your-app.com/callback": "e.g.: https://your-app.com/callback",
+  "添加重定向URI": "Add Redirect URI",
+  "用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)": "After authorization, the user will be redirected to these URIs. HTTPS is required (HTTP is allowed only for localhost/127.0.0.1 during local development).",
+  "仅在选择“授权码”授权类型时需要配置重定向URI": "Redirect URIs are only required when Authorization Code is selected.",
+  "请至少选择一种授权类型": "Please select at least one grant type",
+  "不被允许的授权类型: {{types}}": "Disallowed grant types: {{types}}",
+  "公开客户端不允许使用client_credentials授权类型": "Public clients cannot use the client_credentials grant type.",
+  "选择授权码授权类型时,必须填写至少一个重定向URI": "At least one Redirect URI is required when Authorization Code is selected.",
+  "重定向URI格式不合法:仅支持https,或本地开发使用http": "Invalid Redirect URI: only HTTPS is supported, or HTTP for local development.",
+  "OAuth2客户端更新成功": "OAuth2 client updated successfully",
+  "OAuth2客户端创建成功": "OAuth2 client created successfully",
+  "客户端创建成功": "Client Created Successfully",
+  "请妥善保存以下信息:": "Please keep the following information secure:",
+  "客户端信息如下,请立即复制保存。关闭此窗口后将无法再次查看密钥。": "Client information is shown below. Please copy and save immediately. The secret will not be viewable again after closing this window.",
+  "客户端密钥(仅此一次显示)": "Client Secret (shown only once)",
+  "客户端密钥仅显示一次,请立即复制保存。": "The client secret is shown only once. Please copy and save it immediately.",
+  "公开客户端无需密钥。": "Public clients do not require a client secret.",
+  "更新OAuth2客户端失败": "Failed to update OAuth2 client",
+  "创建OAuth2客户端失败": "Failed to create OAuth2 client",
+  "创建": "Create",
+  "编辑OAuth2客户端": "Edit OAuth2 Client",
+  "创建OAuth2客户端": "Create OAuth2 Client",
+  "设置客户端的基本信息": "Set the client's basic information",
+  "输入客户端名称": "Enter client name",
+  "请输入客户端名称": "Please enter the client name",
+  "输入客户端描述": "Enter client description",
+  "客户端类型": "Client Type",
+  "选择客户端类型": "Select client type",
+  "请选择客户端类型": "Please select client type",
+  "服务器端应用,安全地存储客户端密钥": "Server-side app, can securely store the client secret",
+  "机密客户端(Confidential)": "Confidential Client",
+  "移动应用或单页应用,无法安全存储密钥": "Mobile or single-page app, cannot securely store a secret",
+  "公开客户端(Public)": "Public Client",
+  "请选择授权类型(可多选)": "Select grant types (multiple)",
+  "请选择至少一个权限范围": "Please select at least one scope",
+  "请选择权限范围(可多选)": "Select scopes (multiple)",
+  "openid(OIDC 基础身份)": "openid (OIDC basic identity)",
+  "profile(用户名/昵称等)": "profile (username/nickname, etc.)",
+  "email(邮箱信息)": "email (email information)",
+  "读取API": "read API",
+  "写入API": "write API",
+  "admin(管理员权限)": "admin (administrator permission)",
+  "PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。": "PKCE (Proof Key for Code Exchange) improves the security of the authorization code flow.",
+  "请选择状态": "Please select status",
   "加载OAuth2客户端失败": "Failed to load OAuth2 clients",
   "删除成功": "Deleted successfully",
   "删除失败": "Delete failed",
@@ -2162,6 +2210,7 @@
   "用于标识JWT签名密钥,支持密钥轮换": "Identifier for JWT signing key; supports key rotation",
   "授权配置": "Authorization Settings",
   "允许的授权类型": "Allowed grant types",
+  "允许的权限范围(Scope)": "Allowed scopes",
   "选择允许的OAuth2授权流程": "Select allowed OAuth2 grant flows",
   "Client Credentials(客户端凭证)": "Client Credentials",
   "Authorization Code(授权码)": "Authorization Code",