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() { } /> } /> } /> + } /> } /> } /> } /> 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(); + const [loading, setLoading] = useState(false); + const [sslEnabled, setSslEnabled] = useState(false); + const [sslConfig, setSslConfig] = useState({ + 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 ( +
+ + + {t('ssl.title')} + + } + bordered={false} + style={{ borderRadius: 8, boxShadow: '0 1px 6px rgba(0,0,0,0.04)' }} + > + + +
+ + } + unCheckedChildren={} + /> + + + {sslEnabled && ( + <> + + + + + + + + + + + {t('ssl.keystoreConfig')} + + + + + + + + + + + + + + + + + + + + {form.getFieldValue('clientAuth') !== 'none' && ( + <> + {t('ssl.truststoreConfig')} + + + + + + + + + + + + + + + + + + + + )} + + + + + + + + + + + )} + +
+ + {sslEnabled && sslConfig.certificateExpiry && ( + + + {t('ssl.certInfo')} + + } + bordered={false} + style={{ marginTop: 16, borderRadius: 8, boxShadow: '0 1px 6px rgba(0,0,0,0.04)' }} + > + + + {sslConfig.certificateIssuer} + + + {sslConfig.certificateExpiry} + + + {sslConfig.protocol} + + + }> + {t('ssl.active')} + + + + + )} +
+ ); +}; + +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( + + {ui} + , + ); +}; + +describe('SslSettings Page', () => { + it('should render the page title', () => { + renderWithProviders(); + expect(screen.getByText('SSL/TLS 设置')).toBeInTheDocument(); + }); + + it('should render the info alert', () => { + renderWithProviders(); + expect(screen.getByText('SSL/TLS 配置')).toBeInTheDocument(); + }); + + it('should render the SSL enable switch label', () => { + renderWithProviders(); + expect(screen.getByText('启用 SSL/TLS')).toBeInTheDocument(); + }); + + it('should not show SSL config fields when SSL is disabled', () => { + renderWithProviders(); + expect(screen.queryByText('KeyStore 配置')).not.toBeInTheDocument(); + }); + + it('should show SSL config fields after toggling SSL switch', async () => { + const user = userEvent.setup(); + renderWithProviders(); + 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(); + const switchEl = screen.getByRole('switch'); + await user.click(switchEl); + expect(screen.getByText('KeyStore 类型')).toBeInTheDocument(); + expect(screen.getByText('KeyStore 路径')).toBeInTheDocument(); + expect(screen.getByText('KeyStore 密码')).toBeInTheDocument(); + }); +});