This is an automated email from the ASF dual-hosted git repository. lizhimins pushed a commit to branch rocketmq-studio in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
commit 9745763b34571a2d012500011324e88e4f2305e2 Author: zhaohai <[email protected]> AuthorDate: Wed Jul 22 19:21:52 2026 +0800 feat: add SslSettings page for SSL/TLS configuration management (#477) Add SSL/TLS configuration page: SSL toggle, TLS version, KeyStore/TrustStore, client auth mode. --- web/src/App.tsx | 2 + web/src/pages/studio/SslSettings.tsx | 327 +++++++++++++++++++++ .../pages/studio/__tests__/SslSettings.test.tsx | 98 ++++++ 3 files changed, 427 insertions(+) diff --git a/web/src/App.tsx b/web/src/App.tsx index e21f40c5..5ace3a4e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import SystemAlertsPage from './pages/ops/systemAlerts'; import AuditPage from './pages/ops/audit'; import AiPage from './pages/ai'; import SettingsPage from './pages/settings'; +import SslSettingsPage from './pages/studio/SslSettings'; import AlertManagementPage from './pages/studio/AlertManagement'; import ProducerPage from './pages/studio/Producer'; import OpsPage from './pages/studio/Ops'; @@ -59,6 +60,7 @@ function App() { <Route path="ops/audit" element={<AuditPage />} /> <Route path="ai" element={<AiPage />} /> <Route path="settings" element={<SettingsPage />} /> + <Route path="studio/ssl-settings" element={<SslSettingsPage />} /> <Route path="studio/alert-management" element={<AlertManagementPage />} /> <Route path="studio/producer" element={<ProducerPage />} /> <Route path="studio/ops" element={<OpsPage />} /> diff --git a/web/src/pages/studio/SslSettings.tsx b/web/src/pages/studio/SslSettings.tsx new file mode 100644 index 00000000..5359d953 --- /dev/null +++ b/web/src/pages/studio/SslSettings.tsx @@ -0,0 +1,327 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; +import { + Card, + Form, + Input, + Button, + Switch, + Select, + Upload, + Descriptions, + Tag, + Space, + Alert, + Divider, + Row, + Col, + App, +} from 'antd'; +import { ShieldCheck, Lock, CheckCircle, XCircle, UploadSimple } from '@phosphor-icons/react'; +import { useLang } from '../../i18n/LangContext'; + +// ─── Types ────────────────────────────────────────────────────── +interface SslConfig { + enabled: boolean; + protocol: string; + keyStoreType: string; + keyStorePath: string; + keyStorePassword: string; + trustStoreType: string; + trustStorePath: string; + trustStorePassword: string; + clientAuth: string; + certificateExpiry: string | null; + certificateIssuer: string | null; +} + +interface FormValues { + enabled: boolean; + protocol: string; + clientAuth: string; + keyStoreType: string; + keyStorePath: string; + keyStorePassword: string; + trustStoreType?: string; + trustStorePath?: string; + trustStorePassword?: string; +} + +// ─── Component ────────────────────────────────────────────────── +const SslSettingsPage = () => { + const [form] = Form.useForm<FormValues>(); + const [loading, setLoading] = useState(false); + const [sslEnabled, setSslEnabled] = useState(false); + const [sslConfig, setSslConfig] = useState<SslConfig>({ + enabled: false, + protocol: 'TLSv1.3', + keyStoreType: 'JKS', + keyStorePath: '', + keyStorePassword: '', + trustStoreType: 'JKS', + trustStorePath: '', + trustStorePassword: '', + clientAuth: 'none', + certificateExpiry: '2025-12-31', + certificateIssuer: "Let's Encrypt", + }); + const { t } = useLang(); + const { message } = App.useApp(); + + const handleSave = (values: FormValues) => { + setLoading(true); + setTimeout(() => { + setLoading(false); + message.success(t('ssl.saveSuccess')); + setSslConfig({ ...sslConfig, ...values }); + }, 1000); + }; + + const handleToggleSsl = (checked: boolean) => { + setSslEnabled(checked); + form.setFieldsValue({ enabled: checked }); + }; + + const uploadProps = { + name: 'certificate', + multiple: false, + beforeUpload: (file: File) => { + const isCert = + file.name.endsWith('.pem') || + file.name.endsWith('.crt') || + file.name.endsWith('.jks') || + file.name.endsWith('.p12'); + if (!isCert) { + message.error(t('ssl.invalidCertFormat')); + return false; + } + return false; + }, + onChange: (info: { file: { status?: string } }) => { + if (info.file.status === 'removed') { + message.info(t('ssl.certRemoved')); + } + }, + }; + + return ( + <div style={{ padding: 0 }}> + <Card + title={ + <Space> + <ShieldCheck size={18} style={{ color: '#1677ff' }} /> + <span>{t('ssl.title')}</span> + </Space> + } + bordered={false} + style={{ borderRadius: 8, boxShadow: '0 1px 6px rgba(0,0,0,0.04)' }} + > + <Alert + message={t('ssl.info')} + description={t('ssl.infoDesc')} + type="info" + showIcon + style={{ marginBottom: 24 }} + /> + + <Form form={form} layout="vertical" onFinish={handleSave} initialValues={sslConfig}> + <Form.Item name="enabled" label={t('ssl.enabled')} valuePropName="checked"> + <Switch + checked={sslEnabled} + onChange={handleToggleSsl} + checkedChildren={<CheckCircle size={12} />} + unCheckedChildren={<XCircle size={12} />} + /> + </Form.Item> + + {sslEnabled && ( + <> + <Divider /> + + <Row gutter={16}> + <Col span={12}> + <Form.Item + name="protocol" + label={t('ssl.protocol')} + rules={[{ required: true, message: t('ssl.selectProtocol') }]} + > + <Select + options={[ + { value: 'TLSv1.3', label: 'TLS 1.3' }, + { value: 'TLSv1.2', label: 'TLS 1.2' }, + { value: 'TLSv1.1', label: 'TLS 1.1 (Deprecated)' }, + { value: 'TLSv1', label: 'TLS 1.0 (Deprecated)' }, + ]} + /> + </Form.Item> + </Col> + <Col span={12}> + <Form.Item + name="clientAuth" + label={t('ssl.clientAuth')} + rules={[{ required: true }]} + > + <Select + options={[ + { value: 'none', label: t('ssl.none') }, + { value: 'want', label: t('ssl.want') }, + { value: 'need', label: t('ssl.need') }, + ]} + /> + </Form.Item> + </Col> + </Row> + + <Divider orientation="left">{t('ssl.keystoreConfig')}</Divider> + + <Row gutter={16}> + <Col span={12}> + <Form.Item + name="keyStoreType" + label={t('ssl.keystoreType')} + rules={[{ required: true }]} + > + <Select + options={[ + { value: 'JKS', label: 'JKS' }, + { value: 'PKCS12', label: 'PKCS12' }, + ]} + /> + </Form.Item> + </Col> + <Col span={12}> + <Form.Item + name="keyStorePath" + label={t('ssl.keystorePath')} + rules={[{ required: true, message: t('ssl.keystorePathPlaceholder') }]} + > + <Input placeholder="/path/to/keystore.jks" /> + </Form.Item> + </Col> + </Row> + + <Form.Item + name="keyStorePassword" + label={t('ssl.keystorePassword')} + rules={[{ required: true, message: t('ssl.keystorePasswordPlaceholder') }]} + > + <Input.Password placeholder={t('ssl.keystorePasswordPlaceholder')} /> + </Form.Item> + + <Form.Item label={t('ssl.uploadKeystore')}> + <Upload {...uploadProps}> + <Button icon={<UploadSimple size={14} />}>{t('ssl.upload')}</Button> + </Upload> + </Form.Item> + + {form.getFieldValue('clientAuth') !== 'none' && ( + <> + <Divider orientation="left">{t('ssl.truststoreConfig')}</Divider> + + <Row gutter={16}> + <Col span={12}> + <Form.Item + name="trustStoreType" + label={t('ssl.truststoreType')} + rules={[{ required: true }]} + > + <Select + options={[ + { value: 'JKS', label: 'JKS' }, + { value: 'PKCS12', label: 'PKCS12' }, + ]} + /> + </Form.Item> + </Col> + <Col span={12}> + <Form.Item + name="trustStorePath" + label={t('ssl.truststorePath')} + rules={[{ required: true, message: t('ssl.truststorePathPlaceholder') }]} + > + <Input placeholder="/path/to/truststore.jks" /> + </Form.Item> + </Col> + </Row> + + <Form.Item + name="trustStorePassword" + label={t('ssl.truststorePassword')} + rules={[{ required: true, message: t('ssl.truststorePasswordPlaceholder') }]} + > + <Input.Password placeholder={t('ssl.truststorePasswordPlaceholder')} /> + </Form.Item> + + <Form.Item label={t('ssl.uploadTruststore')}> + <Upload {...uploadProps}> + <Button icon={<UploadSimple size={14} />}>{t('ssl.upload')}</Button> + </Upload> + </Form.Item> + </> + )} + + <Divider /> + + <Form.Item> + <Space> + <Button type="primary" htmlType="submit" loading={loading}> + {t('ssl.save')} + </Button> + <Button onClick={() => form.resetFields()}>{t('common.reset')}</Button> + </Space> + </Form.Item> + </> + )} + </Form> + </Card> + + {sslEnabled && sslConfig.certificateExpiry && ( + <Card + title={ + <Space> + <Lock size={18} style={{ color: '#1677ff' }} /> + <span>{t('ssl.certInfo')}</span> + </Space> + } + bordered={false} + style={{ marginTop: 16, borderRadius: 8, boxShadow: '0 1px 6px rgba(0,0,0,0.04)' }} + > + <Descriptions bordered column={2}> + <Descriptions.Item label={t('ssl.issuer')}> + <Tag color="blue">{sslConfig.certificateIssuer}</Tag> + </Descriptions.Item> + <Descriptions.Item label={t('ssl.expiryDate')}> + <Tag color="green">{sslConfig.certificateExpiry}</Tag> + </Descriptions.Item> + <Descriptions.Item label={t('ssl.protocol')}> + <Tag>{sslConfig.protocol}</Tag> + </Descriptions.Item> + <Descriptions.Item label={t('common.status')}> + <Tag color="success" icon={<CheckCircle size={12} />}> + {t('ssl.active')} + </Tag> + </Descriptions.Item> + </Descriptions> + </Card> + )} + </div> + ); +}; + +export default SslSettingsPage; diff --git a/web/src/pages/studio/__tests__/SslSettings.test.tsx b/web/src/pages/studio/__tests__/SslSettings.test.tsx new file mode 100644 index 00000000..e02b60fe --- /dev/null +++ b/web/src/pages/studio/__tests__/SslSettings.test.tsx @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { App } from 'antd'; +import { LangProvider } from '../../../i18n/LangContext'; +import SslSettings from '../SslSettings'; + +// Mock matchMedia for antd responsive components +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +// Mock react-router-dom +vi.mock('react-router-dom', () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({}), +})); + +const renderWithProviders = (ui: React.ReactElement) => { + return render( + <App> + <LangProvider>{ui}</LangProvider> + </App>, + ); +}; + +describe('SslSettings Page', () => { + it('should render the page title', () => { + renderWithProviders(<SslSettings />); + expect(screen.getByText('SSL/TLS 设置')).toBeInTheDocument(); + }); + + it('should render the info alert', () => { + renderWithProviders(<SslSettings />); + expect(screen.getByText('SSL/TLS 配置')).toBeInTheDocument(); + }); + + it('should render the SSL enable switch label', () => { + renderWithProviders(<SslSettings />); + expect(screen.getByText('启用 SSL/TLS')).toBeInTheDocument(); + }); + + it('should not show SSL config fields when SSL is disabled', () => { + renderWithProviders(<SslSettings />); + expect(screen.queryByText('KeyStore 配置')).not.toBeInTheDocument(); + }); + + it('should show SSL config fields after toggling SSL switch', async () => { + const user = userEvent.setup(); + renderWithProviders(<SslSettings />); + const switchEl = screen.getByRole('switch'); + await user.click(switchEl); + // After toggling, SSL config fields should appear (may appear multiple times in labels + options) + const protocolLabels = screen.getAllByText('SSL 协议'); + expect(protocolLabels.length).toBeGreaterThan(0); + expect(screen.getByText('客户端认证')).toBeInTheDocument(); + expect(screen.getByText('KeyStore 配置')).toBeInTheDocument(); + }); + + it('should show KeyStore fields after enabling SSL', async () => { + const user = userEvent.setup(); + renderWithProviders(<SslSettings />); + const switchEl = screen.getByRole('switch'); + await user.click(switchEl); + expect(screen.getByText('KeyStore 类型')).toBeInTheDocument(); + expect(screen.getByText('KeyStore 路径')).toBeInTheDocument(); + expect(screen.getByText('KeyStore 密码')).toBeInTheDocument(); + }); +});
