🎨 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.
This commit is contained in:
t0ng7u
2025-09-23 03:49:53 +08:00
parent 359dbc9d94
commit 315eabc1e7
6 changed files with 877 additions and 1004 deletions

View File

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

View File

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

View File

@@ -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 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: (
<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'>openidOIDC 基础身份</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(
'PKCEProof 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;

View File

@@ -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 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 (
<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'>openidOIDC 基础身份</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('PKCEProof 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;

View File

@@ -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 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 (
<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校验允许 httpshttp 仅限本地开发域名
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('openidOIDC 基础身份)')}</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(
'PKCEProof 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;

View File

@@ -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)",
"openidOIDC 基础身份)": "openid (OIDC basic identity)",
"profile用户名/昵称等)": "profile (username/nickname, etc.)",
"email邮箱信息": "email (email information)",
"读取API": "read API",
"写入API": "write API",
"admin管理员权限": "admin (administrator permission)",
"PKCEProof 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",