diff --git a/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx b/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx index 74f432801..5c24d5631 100644 --- a/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx +++ b/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() { } /> - {/* 创建客户端模态框 */} - setShowCreateModal(false)} - onSuccess={() => { - setShowCreateModal(false); - loadClients(); - }} - /> - - {/* 编辑客户端模态框 */} - { - setShowEditModal(false); + setShowModal(false); setEditingClient(null); }} onSuccess={() => { - setShowEditModal(false); + setShowModal(false); setEditingClient(null); loadClients(); }} diff --git a/web/src/components/settings/oauth2/modals/ClientInfoModal.jsx b/web/src/components/settings/oauth2/modals/ClientInfoModal.jsx new file mode 100644 index 000000000..a89fd2da4 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + + + +
+
+
+ + {t('客户端ID')} + + + {clientId} + +
+
+ + {clientSecret && ( +
+
+ + {t('客户端密钥(仅此一次显示)')} + + + {clientSecret} + +
+
+ )} +
+
+ ); +}; + +export default ClientInfoModal; diff --git a/web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx b/web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx deleted file mode 100644 index 771c5e206..000000000 --- a/web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx +++ /dev/null @@ -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 . - -For commercial licensing, please contact support@quantumnous.com -*/ - -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: ( -
- {t('请妥善保存以下信息:')} -
-
- {t('客户端ID')}: -
- - {client_id} - -
- {client_secret && ( -
- {t('客户端密钥(仅此一次显示)')}: -
- - {client_secret} - -
- )} -
- - {client_secret - ? t('客户端密钥仅显示一次,请立即复制保存。') - : t('公开客户端无需密钥。')} - -
- ), - 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 ( - formApi?.submitForm()} - okText={t('创建')} - cancelText={t('取消')} - confirmLoading={loading} - width='90vw' - style={{ - top: 20, - maxWidth: '800px', - '@media (min-width: 768px)': { - width: '600px', - }, - }} - > -
setFormApi(api)} - initValues={{ - // 表单默认值优化:预置 OIDC 常用 scope - scopes: ['openid', 'profile', 'email', 'api:read'], - require_pkce: true, - grant_types: grantTypes, - }} - onSubmit={handleSubmit} - labelPosition='top' - > - {/* 基本信息 */} - - - - - - - - - - {/* 客户端类型 */} -
- {t('客户端类型')} - - {t('选择适合您应用程序的客户端类型。')} - - - -
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', - }} - > - {t('机密客户端(Confidential)')} - - {t('用于服务器端应用,可以安全地存储客户端密钥')} - -
- - -
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', - }} - > - {t('公开客户端(Public)')} - - {t('用于移动应用或单页应用,无法安全存储密钥')} - -
- -
-
- - - {/* 授权类型 */} - - - - - - - - - {/* Scope */} - - - - - - - - - - - - {/* PKCE设置 */} - - - - {t( - 'PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。', - )} - - - - - {/* 重定向URI */} - {(grantTypes.includes('authorization_code') || - redirectUris.length > 0) && ( - <> - {t('重定向URI配置')} -
- {t('重定向URI')} - - {t( - '用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。', - )} - - -
- {redirectUris.map((uri, index) => ( - - 1 ? 20 : 24}> - updateRedirectUri(index, value)} - style={{ width: '100%' }} - /> - - {redirectUris.length > 1 && ( - - - - )} - - ))} -
- - -
- - )} -
-
- ); -}; - -export default CreateOAuth2ClientModal; diff --git a/web/src/components/settings/oauth2/modals/EditOAuth2ClientModal.jsx b/web/src/components/settings/oauth2/modals/EditOAuth2ClientModal.jsx deleted file mode 100644 index 601f05b47..000000000 --- a/web/src/components/settings/oauth2/modals/EditOAuth2ClientModal.jsx +++ /dev/null @@ -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 . - -For commercial licensing, please contact support@quantumnous.com -*/ - -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 ( - formApi?.submitForm()} - okText={t('保存')} - cancelText={t('取消')} - confirmLoading={loading} - width={600} - style={{ top: 50 }} - > -
setFormApi(api)} - onSubmit={handleSubmit} - labelPosition='top' - > - {/* 客户端ID(只读) */} - - - {/* 基本信息 */} - - - - - {/* 客户端类型(只读) */} - - - - - - - {t('客户端类型创建后不可更改。')} - - - {/* 授权类型 */} - - - - - - - {/* Scope */} - - - - - - - - - - {/* PKCE设置 */} - - - {t('PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。')} - - - {/* 状态 */} - - - - - - {/* 重定向URI */} - {(grantTypes.includes('authorization_code') || - redirectUris.length > 0) && ( - <> - {t('重定向URI配置')} -
- {t('重定向URI')} - - {t( - '用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。', - )} - - - - {redirectUris.map((uri, index) => ( - - updateRedirectUri(index, value)} - style={{ flex: 1 }} - /> - {redirectUris.length > 1 && ( - - )} - - ))} - - - -
- - )} - -
- ); -}; - -export default EditOAuth2ClientModal; diff --git a/web/src/components/settings/oauth2/modals/OAuth2ClientModal.jsx b/web/src/components/settings/oauth2/modals/OAuth2ClientModal.jsx new file mode 100644 index 000000000..363f5eda0 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + +
+ + + +
+ {t('重定向URI配置')} +
+ {t('用于授权码流程的重定向地址')} +
+
+
+ + + } + headerStyle={{ padding: '12px 16px' }} + bodyStyle={{ padding: '16px' }} + className='!rounded-2xl shadow-sm border-0' + > +
+ {redirectUris.length === 0 && ( +
+ + {t('暂无重定向URI,点击下方按钮添加')} + +
+ )} + + {redirectUris.map((uri, index) => ( +
+ onUpdate(index, value)} + style={{ flex: 1 }} + disabled={!isAuthCodeSelected} + /> +
+ ))} + +
+ +
+
+ + + + {isAuthCodeSelected + ? t( + '用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)', + ) + : t('仅在选择“授权码”授权类型时需要配置重定向URI')} + + +
+ ); +} + +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 ( + + {mode === 'edit' ? ( + + {t('编辑')} + + ) : ( + + {t('创建')} + + )} + + {mode === 'edit' ? t('编辑OAuth2客户端') : t('创建OAuth2客户端')} + + + } + bodyStyle={{ padding: '0' }} + visible={visible} + width={isMobile ? '100%' : 700} + footer={ +
+ + + + +
+ } + closeIcon={null} + onCancel={handleCancel} + > + +
(formApiRef.current = api)} + onSubmit={handleSubmit} + > + {() => ( +
+ {/* 表单内容 */} + {/* 基本信息 */} + +
+ + + +
+ {t('基本信息')} +
+ {t('设置客户端的基本信息')} +
+
+
+ {isEdit && ( + <> + + + + + + + )} + + + + + {t('机密客户端(Confidential)')} + + + {t('公开客户端(Public)')} + + + + {clientType !== 'public' && ( + + )} + + + + + + + + + + + + +
+ + {/* 重定向URI */} + +
+ )} +
+
+ + {/* 客户端信息展示模态框 */} + +
+ ); +}; + +export default OAuth2ClientModal; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b74f30ea9..7f1cc6b62 100644 --- a/web/src/i18n/locales/en.json +++ b/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",