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

Reply via email to