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
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 7a1ac22 feat: add SslSettings page for SSL/TLS configuration
management (#477)
7a1ac22 is described below
commit 7a1ac2282d1932820f6ccfe3d2e9590aaafc875f
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 e21f40c..5ace3a4 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 0000000..5359d95
--- /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 0000000..e02b60f
--- /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();
+ });
+});