/* 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;