diff --git a/frontend/src/components/pages/shadowlinks/create/connection/bootstrap-servers.test.tsx b/frontend/src/components/pages/shadowlinks/create/connection/bootstrap-servers.test.tsx index 4f15f4d4fe..22916bb48b 100644 --- a/frontend/src/components/pages/shadowlinks/create/connection/bootstrap-servers.test.tsx +++ b/frontend/src/components/pages/shadowlinks/create/connection/bootstrap-servers.test.tsx @@ -14,10 +14,16 @@ import userEvent from '@testing-library/user-event'; import { Form } from 'components/redpanda-ui/components/form'; import { useForm } from 'react-hook-form'; import { render, screen, waitFor } from 'test-utils'; +import { vi } from 'vitest'; import { BootstrapServers } from './bootstrap-servers'; import { FormSchema, type FormValues, initialValues } from '../model'; +vi.mock('config', () => ({ + isEmbedded: vi.fn(() => false), + isFeatureFlagEnabled: vi.fn(() => false), +})); + const TestWrapper = ({ defaultValues = initialValues }: { defaultValues?: FormValues }) => { const form = useForm({ resolver: zodResolver(FormSchema), @@ -170,6 +176,79 @@ describe('BootstrapServers', () => { }); }); + describe('TLS disclosures', () => { + test('should hide disclosures and intro when TLS is off', () => { + const customValues: FormValues = { + ...initialValues, + useTls: false, + }; + + render(); + + expect(screen.queryByTestId('tls-intro')).not.toBeInTheDocument(); + expect(screen.queryByTestId('tls-ca-disclosure')).not.toBeInTheDocument(); + expect(screen.queryByTestId('tls-mtls-disclosure')).not.toBeInTheDocument(); + }); + + test('should render intro and both disclosures collapsed by default when TLS is on', () => { + render(); + + expect(screen.getByTestId('tls-intro')).toBeInTheDocument(); + expect(screen.getByTestId('tls-ca-disclosure-trigger')).toBeInTheDocument(); + expect(screen.getByTestId('tls-mtls-disclosure-trigger')).toBeInTheDocument(); + // Bodies are collapsed: cert add buttons should not be present + expect(screen.queryByTestId('add-ca-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('add-clientKey-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('add-clientCert-button')).not.toBeInTheDocument(); + }); + + test('should reveal CA cert button when expanding the CA disclosure', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('tls-ca-disclosure-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('add-ca-button')).toBeInTheDocument(); + }); + }); + + test('should reveal client key first, then certificate, plus pair hint when expanding mTLS', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('tls-mtls-disclosure-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('add-clientKey-button')).toBeInTheDocument(); + expect(screen.getByTestId('add-clientCert-button')).toBeInTheDocument(); + expect(screen.getByTestId('tls-mtls-pair-hint')).toHaveTextContent( + 'Client certificate and private key must be provided together.' + ); + }); + + // Order: client key appears before client certificate in the DOM + const keyButton = screen.getByTestId('add-clientKey-button'); + const certButton = screen.getByTestId('add-clientCert-button'); + expect(keyButton.compareDocumentPosition(certButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + test('should auto-open mTLS disclosure when client cert is already filled', () => { + const customValues: FormValues = { + ...initialValues, + mtls: { + ...initialValues.mtls, + clientCert: { pemContent: 'PEM-CONTENT', fileName: 'client.crt' }, + }, + }; + + render(); + + expect(screen.getByTestId('add-clientKey-button')).toBeInTheDocument(); + expect(screen.getByTestId('clientCert-status')).toBeInTheDocument(); + }); + }); + describe('Integration', () => { test('should handle complete workflow: add broker, fill values, toggle TLS', async () => { const user = userEvent.setup(); diff --git a/frontend/src/components/pages/shadowlinks/create/connection/certificate-dialog.tsx b/frontend/src/components/pages/shadowlinks/create/connection/certificate-dialog.tsx deleted file mode 100644 index fc43b889c5..0000000000 --- a/frontend/src/components/pages/shadowlinks/create/connection/certificate-dialog.tsx +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Copyright 2025 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { Button } from 'components/redpanda-ui/components/button'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from 'components/redpanda-ui/components/dialog'; -import { Dropzone } from 'components/redpanda-ui/components/dropzone'; -import { Input } from 'components/redpanda-ui/components/input'; -import { Label } from 'components/redpanda-ui/components/label'; -import { InlineCode, Text } from 'components/redpanda-ui/components/typography'; -import { useCallback, useState } from 'react'; - -import { TLS_MODE, type TLSMode } from '../model'; - -export type CertificateType = 'ca' | 'clientCert' | 'clientKey'; - -type CertificateValue = { - filePath?: string; - pemContent?: string; - fileName?: string; -}; - -type CertificateDialogProps = { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - certificateType: CertificateType; - mode: TLSMode; - existingValue?: CertificateValue; - onSave: (value: CertificateValue) => void; -}; - -const CERTIFICATE_LABELS: Record = { - ca: 'CA certificate', - clientCert: 'Client certificate', - clientKey: 'Client private key', -}; - -const CERTIFICATE_DESCRIPTIONS: Record = { - ca: 'Certificate Authority certificate to verify server identity', - clientCert: 'Client certificate for mutual TLS authentication', - clientKey: 'Private key for client certificate', -}; - -export function CertificateDialog({ - isOpen, - onOpenChange, - certificateType, - mode, - existingValue, - onSave, -}: CertificateDialogProps) { - const [filePath, setFilePath] = useState(existingValue?.filePath ?? ''); - const [pemContent, setPemContent] = useState(existingValue?.pemContent ?? ''); - const [fileName, setFileName] = useState(existingValue?.fileName ?? ''); - - // Tracks previous isOpen value to detect open/close transitions (not derived state) - const [prevIsOpen, setPrevIsOpen] = useState(() => isOpen); - if (isOpen && !prevIsOpen) { - if (existingValue) { - // Edit mode: populate with existing values - setFilePath(existingValue.filePath ?? ''); - setPemContent(existingValue.pemContent ?? ''); - setFileName(existingValue.fileName ?? ''); - } else { - // Add mode: clear all fields - setFilePath(''); - setPemContent(''); - setFileName(''); - } - } - if (prevIsOpen !== isOpen) { - setPrevIsOpen(isOpen); - } - - const handleFileUpload = useCallback((files: File[]) => { - if (files.length === 0) { - return; - } - - const file = files[0]; - const reader = new FileReader(); - - reader.onload = (e) => { - const content = e.target?.result as string; - setPemContent(content); - setFileName(file.name); - }; - - reader.readAsText(file); - }, []); - - const handleSave = useCallback(() => { - const value: CertificateValue = { - filePath: mode === TLS_MODE.FILE_PATH ? filePath : undefined, - pemContent: mode === TLS_MODE.PEM ? pemContent : undefined, - fileName: mode === TLS_MODE.PEM ? fileName : undefined, - }; - - onSave(value); - onOpenChange(false); - }, [mode, filePath, pemContent, fileName, onSave, onOpenChange]); - - const handleCancel = useCallback(() => { - // Reset to original values - setFilePath(existingValue?.filePath ?? ''); - setPemContent(existingValue?.pemContent ?? ''); - setFileName(existingValue?.fileName ?? ''); - onOpenChange(false); - }, [existingValue, onOpenChange]); - - const isValid = mode === TLS_MODE.FILE_PATH ? filePath.trim().length > 0 : pemContent.trim().length > 0; - - return ( - - - - {CERTIFICATE_LABELS[certificateType]} - - {CERTIFICATE_DESCRIPTIONS[certificateType]} - - - -
- {mode === TLS_MODE.PEM ? ( -
- - {Boolean(fileName) && ( - - {fileName} - - )} - {!fileName && ( - - Drag and drop or click to replace - - )} - -
- ) : ( -
- - setFilePath(e.target.value)} - placeholder="/etc/redpanda/certs/ca.crt" - testId={`${certificateType}-file-path-input`} - value={filePath} - /> - - The certificate must reside on the broker. Provide a relative path from the broker's configuration - directory. Example: /etc/redpanda/certs/ca.crt - -
- )} -
- - - - - -
-
- ); -} diff --git a/frontend/src/components/pages/shadowlinks/create/connection/mtls-configuration.tsx b/frontend/src/components/pages/shadowlinks/create/connection/mtls-configuration.tsx index 3962b11931..f4fc53c293 100644 --- a/frontend/src/components/pages/shadowlinks/create/connection/mtls-configuration.tsx +++ b/frontend/src/components/pages/shadowlinks/create/connection/mtls-configuration.tsx @@ -9,27 +9,35 @@ * by the Apache License, Version 2.0 */ -import { Button } from 'components/redpanda-ui/components/button'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from 'components/redpanda-ui/components/collapsible'; +import { Dropzone, DropzoneContent, DropzoneEmptyState } from 'components/redpanda-ui/components/dropzone'; import { FormControl, FormField, FormItem, FormLabel, FormMessage } from 'components/redpanda-ui/components/form'; import { Input } from 'components/redpanda-ui/components/input'; import { Tabs, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { cn } from 'components/redpanda-ui/lib/utils'; import { SecretSelector, type SecretSelectorCustomText } from 'components/ui/secret/secret-selector'; import { isEmbedded } from 'config'; -import { Pencil, Trash2 } from 'lucide-react'; +import { Check, ChevronRight, Trash2, UploadCloud } from 'lucide-react'; import { Scope } from 'protogen/redpanda/api/dataplane/v1/secret_pb'; -import { useMemo, useState } from 'react'; -import type { Control, FieldErrors } from 'react-hook-form'; +import { useCallback, useMemo, useState } from 'react'; +import type { FieldErrors } from 'react-hook-form'; import { useFormContext, useFormState, useWatch } from 'react-hook-form'; import { useListSecretsQuery } from 'react-query/api/secret'; -import { CertificateDialog, type CertificateType } from './certificate-dialog'; -import { Text } from '../../../../redpanda-ui/components/typography'; +import { extractSecretId, toSecretReference } from './secret-reference'; import type { FormValues } from '../model'; -// Regex to extract secret ID from ${secrets.SECRET_NAME} format -const SECRET_REFERENCE_REGEX = /^\$\{secrets\.([^}]+)\}$/; +export type CertificateType = 'ca' | 'clientCert' | 'clientKey'; + +const CERTIFICATE_ACCEPT = { + 'application/x-pem-file': ['.pem'], + 'application/x-x509-ca-cert': ['.crt', '.cer'], + 'application/pkix-cert': ['.crt', '.cer'], + 'application/x-pkcs12': ['.key'], +}; -/** Custom text for mTLS client key secret */ const MTLS_CLIENT_KEY_SECRET_TEXT: SecretSelectorCustomText = { dialogDescription: 'Create a new secret for your mTLS client private key. The secret will be stored securely.', secretNamePlaceholder: 'e.g., MTLS_CLIENT_KEY', @@ -56,70 +64,100 @@ const CERTIFICATE_TEST_ID_SUFFIXES: Record = { clientKey: 'client-key', }; -type CertificateButtonProps = { - certType: CertificateType; - cert: { filePath?: string; pemContent?: string; fileName?: string } | undefined; - onOpenDialog: (certType: CertificateType) => void; - onRemove: (certType: CertificateType) => void; -}; +type Cert = NonNullable['ca']; -function CertificateButton({ certType, cert, onOpenDialog, onRemove }: CertificateButtonProps) { - const label = CERTIFICATE_LABELS[certType] ?? 'Certificate'; - const hasCert = Boolean(cert?.filePath || cert?.pemContent); +const hasCertValue = (cert: Cert): boolean => Boolean(cert?.filePath || cert?.pemContent); - if (!hasCert) { - return ( - - ); - } +// --------------------------------------------------------------------------- +// Inline cert dropzone (PEM upload — replaces the old modal-based flow). +// Self-contained: reads/writes form state directly via useFormContext. +// --------------------------------------------------------------------------- - const displayValue = cert?.fileName || cert?.filePath || 'Certificate added'; +function CertificateDropzone({ certType }: { certType: CertificateType }) { + const { control, setValue } = useFormContext(); + const cert = useWatch({ control, name: `mtls.${certType}` }); + const label = CERTIFICATE_LABELS[certType]; + const hasCert = hasCertValue(cert); + const fileName = cert?.fileName ?? cert?.filePath; + + // Dropzone uses src to decide between empty/filled rendering. Build a placeholder + // File object purely so DropzoneContent renders — the real PEM bytes live in form state. + const src = useMemo(() => (fileName ? [new File([], fileName)] : undefined), [fileName]); + + const handleDrop = useCallback( + (files: File[]) => { + const file = files[0]; + if (!file) { + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + const content = (event.target?.result as string | undefined) ?? ''; + setValue(`mtls.${certType}`, { pemContent: content, fileName: file.name }); + }; + reader.readAsText(file); + }, + [certType, setValue] + ); + + const handleRemove = useCallback(() => { + setValue(`mtls.${certType}`, undefined); + }, [certType, setValue]); return ( -
-
- {label} - {displayValue} +
+
+ {label} + · optional
-
- - + + + Upload file + + +
+ + {fileName} +
+
+ + {hasCert && ( + + )}
); } -type CertificateInputFieldProps = { - certType: CertificateType; - control: Control; - cert: { filePath?: string; pemContent?: string; fileName?: string } | undefined; -}; +// --------------------------------------------------------------------------- +// File-path input (alternative cert input mode for non-embedded users). +// --------------------------------------------------------------------------- -function CertificateInputField({ certType, control, cert }: CertificateInputFieldProps) { - const label = CERTIFICATE_LABELS[certType] ?? 'Certificate'; - const placeholder = CERTIFICATE_PLACEHOLDERS[certType] ?? '/path/to/certificate'; - const testIdSuffix = CERTIFICATE_TEST_ID_SUFFIXES[certType] ?? 'client-key'; +function CertificateInputField({ certType }: { certType: CertificateType }) { + const { control } = useFormContext(); + const label = CERTIFICATE_LABELS[certType]; + const placeholder = CERTIFICATE_PLACEHOLDERS[certType]; + const testIdSuffix = CERTIFICATE_TEST_ID_SUFFIXES[certType]; return ( ( - {label} path + + {label} path + · optional + { const value = e.target.value.trim(); field.onChange(value ? { filePath: value } : undefined); @@ -137,7 +179,7 @@ function CertificateInputField({ certType, control, cert }: CertificateInputFiel placeholder={placeholder} testId={`mtls-${testIdSuffix}-path-input`} type="text" - value={cert?.filePath || ''} + value={field.value?.filePath || ''} /> @@ -147,242 +189,258 @@ function CertificateInputField({ certType, control, cert }: CertificateInputFiel ); } -type MtlsCertificatesUploadProps = { - control: Control; - errors: FieldErrors; - certs: { - ca: { filePath?: string; pemContent?: string; fileName?: string } | undefined; - clientCert: { filePath?: string; pemContent?: string; fileName?: string } | undefined; - clientKey: { filePath?: string; pemContent?: string; fileName?: string } | undefined; - }; - onOpenDialog: (certType: CertificateType) => void; - onRemove: (certType: CertificateType) => void; - hideClientKey?: boolean; -}; +// --------------------------------------------------------------------------- +// Embedded-mode client-key picker (lives in Cloud Secrets, not as a file). +// --------------------------------------------------------------------------- -const MtlsCertificatesUpload = ({ - control, - errors, - certs, - onOpenDialog, - onRemove, - hideClientKey, -}: MtlsCertificatesUploadProps) => ( -
-
- ( - - - - )} - /> - - ( - - - - )} - /> - - {!hideClientKey && ( - ( - - - - )} - /> - )} -
+function ClientKeySecretField() { + const { control } = useFormContext(); - {Boolean(errors.mtls?.ca || errors.mtls?.clientCert || errors.mtls?.clientKey) && ( -
- {Boolean(errors.mtls?.ca?.message) && ( - {String(errors.mtls?.ca?.message)} - )} - {Boolean(errors.mtls?.clientCert?.message) && ( - {String(errors.mtls?.clientCert?.message)} - )} - {Boolean(errors.mtls?.clientKey?.message) && ( - {String(errors.mtls?.clientKey?.message)} - )} -
- )} -
-); - -export const MtlsConfiguration = () => { - const { control, setValue } = useFormContext(); - const { errors } = useFormState({ control }); - const useTls = useWatch({ control, name: 'useTls' }); - const mtlsMode = useWatch({ control, name: 'mtlsMode' }); - const mtls = useWatch({ control, name: 'mtls' }); - - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [currentCertType, setCurrentCertType] = useState('ca'); - - // Fetch secrets for SecretSelector (embedded mode only) const { data: secretsData } = useListSecretsQuery({}, { enabled: isEmbedded() }); const availableSecrets = useMemo(() => { if (!secretsData?.secrets) { return []; } return secretsData.secrets - .filter((secret): secret is NonNullable & { id: string } => !!secret?.id) - .map((secret) => ({ - id: secret.id, - name: secret.id, - })); + .filter((secret): secret is NonNullable & { id: string } => Boolean(secret?.id)) + .map((secret) => ({ id: secret.id, name: secret.id })); }, [secretsData]); - // Helper to extract secret ID from ${secrets.SECRET_NAME} format - const extractSecretId = (pemContent: string | undefined): string => { - if (!pemContent) { - return ''; - } - const match = pemContent.match(SECRET_REFERENCE_REGEX); - return match?.[1] || ''; - }; + return ( + ( + + Client private key secret + + { + field.onChange(secretId ? { pemContent: toSecretReference(secretId) } : undefined); + }} + placeholder="Select or create client key secret" + scopes={[Scope.REDPANDA_CLUSTER]} + value={extractSecretId(field.value?.pemContent)} + /> + + + + )} + /> + ); +} - // Don't render if TLS is disabled - if (!useTls) { - return null; - } +// --------------------------------------------------------------------------- +// Disclosure row (used twice: CA and mTLS). +// --------------------------------------------------------------------------- + +type DisclosureRowProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + label: string; + description: string; + configured: boolean; + testId: string; + triggerTestId: string; + children: React.ReactNode; +}; + +function DisclosureRow({ + open, + onOpenChange, + label, + description, + configured, + testId, + triggerTestId, + children, +}: DisclosureRowProps) { + return ( + + + + + + {children} + + + ); +} - const handleOpenDialog = (certType: CertificateType) => { - setCurrentCertType(certType); - setIsDialogOpen(true); - }; +// --------------------------------------------------------------------------- +// Sub-fields per disclosure +// --------------------------------------------------------------------------- - const handleSaveCertificate = (value: { filePath?: string; pemContent?: string; fileName?: string }) => { - setValue(`mtls.${currentCertType}`, value); - }; +function CaCertField({ useFilePath }: { useFilePath: boolean }) { + return useFilePath ? : ; +} - const handleRemoveCertificate = (certType: CertificateType) => { - setValue(`mtls.${certType}`, undefined); - }; +function MtlsCertFields({ useFilePath, embedded }: { useFilePath: boolean; embedded: boolean }) { + if (useFilePath) { + return ( + <> + + + + ); + } + return ( + <> + {embedded ? : } + + + ); +} - const getCertificateValue = (certType: CertificateType) => mtls?.[certType]; +function MtlsErrors({ errors }: { errors: FieldErrors }) { + const messages = (['ca', 'clientCert', 'clientKey'] as const) + .map((key) => errors.mtls?.[key]?.message) + .filter((msg): msg is string => Boolean(msg)); - const certs = { - ca: getCertificateValue('ca'), - clientCert: getCertificateValue('clientCert'), - clientKey: getCertificateValue('clientKey'), - }; + if (messages.length === 0) { + return null; + } return ( - <> -
- - Configure certificates for mutual TLS authentication. Upload embeds certificate content in the configuration, - while file path references certificates already on the broker. Providing certificates enables mTLS; leaving - them empty uses server-side TLS only. +
+ {messages.map((msg) => ( + + {msg} + ))} +
+ ); +} - {!isEmbedded() && ( - ( - - Certificate input method - - { - field.onChange(value); - // Clear all certificates when mode changes - setValue('mtls.ca', undefined); - setValue('mtls.clientCert', undefined); - setValue('mtls.clientKey', undefined); - }} - value={field.value} - > - - - Upload - - - File path - - - - - - )} - /> - )} +// --------------------------------------------------------------------------- +// Top-level component +// --------------------------------------------------------------------------- - {!isEmbedded() && mtlsMode === 'file_path' ? ( -
- - - -
- ) : ( - <> - - {isEmbedded() && ( - ( - - Client private key secret - - { - // Store the complete secret reference structure: ${secrets.} - field.onChange(secretId ? { pemContent: `\${secrets.${secretId}}` } : undefined); - }} - placeholder="Select or create client key secret" - scopes={[Scope.REDPANDA_CLUSTER]} - value={extractSecretId(mtls?.clientKey?.pemContent)} - /> - - - - )} - /> - )} - - )} -
+export const MtlsConfiguration = () => { + const { control, setValue } = useFormContext(); + const { errors } = useFormState({ control }); + const useTls = useWatch({ control, name: 'useTls' }); + const mtlsMode = useWatch({ control, name: 'mtlsMode' }); + const mtls = useWatch({ control, name: 'mtls' }); - - + const hasCa = hasCertValue(mtls?.ca); + const hasClientCert = hasCertValue(mtls?.clientCert); + const hasClientKey = hasCertValue(mtls?.clientKey); + + // Snapshot form state at mount for initial open state. Once mounted, the user + // controls open/close — values arriving later won't re-open the row. + const [caOpen, setCaOpen] = useState(hasCa); + const [mtlsOpen, setMtlsOpen] = useState(hasClientCert || hasClientKey); + + if (!useTls) { + return null; + } + + const useFilePathInputs = !isEmbedded() && mtlsMode === 'file_path'; + const showModePicker = !isEmbedded() && (caOpen || mtlsOpen); + + return ( +
+ {showModePicker && ( + ( + + Certificate input method + + { + field.onChange(value); + // Switching modes invalidates any partially-entered cert values + // because file_path and pem use different storage shapes. + setValue('mtls.ca', undefined); + setValue('mtls.clientCert', undefined); + setValue('mtls.clientKey', undefined); + }} + value={field.value} + > + + + Upload + + + File path + + + + + + )} + /> + )} + + + + + + +
+ + + Client certificate and private key must be provided together. + + +
+
+
); }; diff --git a/frontend/src/components/pages/shadowlinks/create/connection/scram-configuration.test.tsx b/frontend/src/components/pages/shadowlinks/create/connection/scram-configuration.test.tsx index 93f86e9570..0461d33cfb 100644 --- a/frontend/src/components/pages/shadowlinks/create/connection/scram-configuration.test.tsx +++ b/frontend/src/components/pages/shadowlinks/create/connection/scram-configuration.test.tsx @@ -49,7 +49,13 @@ const TestWrapper = ({ defaultValues = initialValues }: { defaultValues?: FormVa describe('ScramConfiguration', () => { describe('SCRAM enabled state', () => { - test('should show credentials form when switch is enabled', () => { + test('should render SASL toggle label', () => { + render(); + + expect(screen.getByText('Use SASL authentication')).toBeInTheDocument(); + }); + + test('should show credentials form and source-cluster callout when switch is enabled', () => { const customValues: FormValues = { ...initialValues, useScram: true, @@ -62,9 +68,14 @@ describe('ScramConfiguration', () => { expect(screen.getByTestId('scram-username-field')).toBeInTheDocument(); expect(screen.getByTestId('scram-password-field')).toBeInTheDocument(); expect(screen.getByTestId('scram-mechanism-field')).toBeInTheDocument(); + + const callout = screen.getByTestId('scram-source-cluster-callout'); + expect(callout).toBeInTheDocument(); + expect(callout).toHaveTextContent('The user must exist on the source cluster.'); + expect(screen.getByRole('link', { name: /view required acls/i })).toBeInTheDocument(); }); - test('should hide credentials form when switch is disabled', () => { + test('should hide credentials form and callout when switch is disabled', () => { const customValues: FormValues = { ...initialValues, useScram: false, @@ -77,6 +88,7 @@ describe('ScramConfiguration', () => { expect(screen.queryByTestId('scram-username-field')).not.toBeInTheDocument(); expect(screen.queryByTestId('scram-password-field')).not.toBeInTheDocument(); expect(screen.queryByTestId('scram-mechanism-field')).not.toBeInTheDocument(); + expect(screen.queryByTestId('scram-source-cluster-callout')).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/components/pages/shadowlinks/create/connection/scram-configuration.tsx b/frontend/src/components/pages/shadowlinks/create/connection/scram-configuration.tsx index d4f2ad5d7b..efaeb50c3a 100644 --- a/frontend/src/components/pages/shadowlinks/create/connection/scram-configuration.tsx +++ b/frontend/src/components/pages/shadowlinks/create/connection/scram-configuration.tsx @@ -9,9 +9,11 @@ * by the Apache License, Version 2.0 */ +import { Alert, AlertDescription, AlertTitle } from 'components/redpanda-ui/components/alert'; import { Card, CardHeader, CardTitle } from 'components/redpanda-ui/components/card'; import { FormControl, FormField, FormItem, FormLabel, FormMessage } from 'components/redpanda-ui/components/form'; import { Input } from 'components/redpanda-ui/components/input'; +import { Label } from 'components/redpanda-ui/components/label'; import { Select, SelectContent, @@ -22,16 +24,18 @@ import { import { Switch } from 'components/redpanda-ui/components/switch'; import { SecretSelector, type SecretSelectorCustomText } from 'components/ui/secret/secret-selector'; import { isEmbedded } from 'config'; +import { ExternalLink, InfoIcon } from 'lucide-react'; import { Scope } from 'protogen/redpanda/api/dataplane/v1/secret_pb'; import { ScramMechanism } from 'protogen/redpanda/core/admin/v2/shadow_link_pb'; import { useMemo } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { useListSecretsQuery } from 'react-query/api/secret'; +import { extractSecretId, toSecretReference } from './secret-reference'; import type { FormValues } from '../model'; -// Regex to extract secret ID from ${secrets.SECRET_NAME} format -const SECRET_REFERENCE_REGEX = /^\$\{secrets\.([^}]+)\}$/; +const SHADOW_LINK_DOCS_URL = + 'https://docs.redpanda.com/current/manage/disaster-recovery/shadowing/setup/#replication-service-permissions'; /** Custom text for SCRAM password secret */ const SCRAM_PASSWORD_SECRET_TEXT: SecretSelectorCustomText = { @@ -54,22 +58,10 @@ export const ScramConfiguration = () => { return []; } return secretsData.secrets - .filter((secret): secret is NonNullable & { id: string } => !!secret?.id) - .map((secret) => ({ - id: secret.id, - name: secret.id, - })); + .filter((secret): secret is NonNullable & { id: string } => Boolean(secret?.id)) + .map((secret) => ({ id: secret.id, name: secret.id })); }, [secretsData]); - // Helper to extract secret ID from ${secrets.SECRET_NAME} format - const extractSecretId = (password: string | undefined): string => { - if (!password) { - return ''; - } - const match = password.match(SECRET_REFERENCE_REGEX); - return match?.[1] || ''; - }; - const handleScramToggle = (checked: boolean) => { setValue('useScram', checked); if (checked) { @@ -92,21 +84,38 @@ export const ScramConfiguration = () => { Authentication - ( - - Use SCRAM authentication - - - - - )} - /> + +
+ + +
{Boolean(useScram) && (
+ } + variant="info" + > + The user must exist on the source cluster. + +

+ Provide credentials for a service account on the source cluster (the one being shadowed) with ACLs to + manage shadow link replication.{' '} + + View required ACLs + + +

+
+
+ { availableSecrets={availableSecrets} customText={SCRAM_PASSWORD_SECRET_TEXT} onChange={(secretId) => { - // Store the complete secret reference structure: ${secrets.} - field.onChange(secretId ? `\${secrets.${secretId}}` : ''); + field.onChange(secretId ? toSecretReference(secretId) : ''); }} placeholder="Select or create password secret" scopes={[Scope.REDPANDA_CLUSTER]} diff --git a/frontend/src/components/pages/shadowlinks/create/connection/secret-reference.ts b/frontend/src/components/pages/shadowlinks/create/connection/secret-reference.ts new file mode 100644 index 0000000000..e84be3b713 --- /dev/null +++ b/frontend/src/components/pages/shadowlinks/create/connection/secret-reference.ts @@ -0,0 +1,16 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +const SECRET_REFERENCE_REGEX = /^\$\{secrets\.([^}]+)\}$/; + +export const extractSecretId = (raw: string | undefined): string => raw?.match(SECRET_REFERENCE_REGEX)?.[1] ?? ''; + +export const toSecretReference = (id: string): string => `\${secrets.${id}}`; diff --git a/frontend/src/components/pages/shadowlinks/create/connection/tls-configuration.tsx b/frontend/src/components/pages/shadowlinks/create/connection/tls-configuration.tsx index 4970920200..c945c60b5c 100644 --- a/frontend/src/components/pages/shadowlinks/create/connection/tls-configuration.tsx +++ b/frontend/src/components/pages/shadowlinks/create/connection/tls-configuration.tsx @@ -11,14 +11,20 @@ import { FormControl, FormField, FormItem, FormLabel } from 'components/redpanda-ui/components/form'; import { Switch } from 'components/redpanda-ui/components/switch'; -import { useFormContext } from 'react-hook-form'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { useFormContext, useWatch } from 'react-hook-form'; import type { FormValues } from '../model'; +const TLS_DOCS_URL = + 'https://docs.redpanda.com/current/manage/disaster-recovery/shadowing/setup/#network-and-authentication'; + export const TlsConfiguration = () => { const { control } = useFormContext(); + const useTls = useWatch({ control, name: 'useTls' }); + return ( -
+
{ )} /> + + {useTls && ( + + The connection to the source cluster is encrypted. By default, its certificate is verified using the system + trust store. Upload a custom CA below if the source uses a private or self-signed CA.{' '} + + Learn about TLS for shadow links + + + )}
); }; diff --git a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx index 9ce11f77ef..94bd780da8 100644 --- a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx +++ b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx @@ -136,7 +136,15 @@ const performCreateAction = async ( await enableTLS(user, scr); break; case 'addCertificateFilePath': { - // First, switch to file path mode + // Expand the disclosure that owns this cert type. CA lives under the CA disclosure; + // clientCert/clientKey live under the mTLS disclosure. + const triggerTestId = action.certType === 'ca' ? 'tls-ca-disclosure-trigger' : 'tls-mtls-disclosure-trigger'; + const trigger = scr.getByTestId(triggerTestId); + if (trigger.getAttribute('data-state') !== 'open') { + await user.click(trigger); + } + + // The mode picker only renders once a disclosure is open. Switch to file path. await waitFor( () => { expect(scr.getByTestId('mtls-mode-file-path-tab')).toBeInTheDocument(); @@ -169,42 +177,31 @@ const performCreateAction = async ( break; } case 'addCertificatePEM': { - await waitFor( - () => { - expect(scr.getByTestId(`add-${action.certType}-button`)).toBeInTheDocument(); - }, - { timeout: 200 } - ); - - const addButton = scr.getByTestId(`add-${action.certType}-button`); - await user.click(addButton); + // Expand the disclosure that owns this cert type, then drop a file directly into + // the inline Dropzone. No modal, no save step. + const pemTriggerTestId = action.certType === 'ca' ? 'tls-ca-disclosure-trigger' : 'tls-mtls-disclosure-trigger'; + const pemTrigger = scr.getByTestId(pemTriggerTestId); + if (pemTrigger.getAttribute('data-state') !== 'open') { + await user.click(pemTrigger); + } await waitFor( () => { - const dropzone = scr.getByTestId('certificate-dropzone'); - expect(dropzone).toBeVisible(); + expect(scr.getByTestId(`add-${action.certType}-button`)).toBeInTheDocument(); }, { timeout: 200 } ); - const file = new File([action.pemContent], `${action.certType}.pem`, { type: 'application/x-pem-file' }); - const dropzone = scr.getByTestId('certificate-dropzone'); + const dropzone = scr.getByTestId(`add-${action.certType}-button`); const input = dropzone.querySelector('input[type="file"]'); + const file = new File([action.pemContent], `${action.certType}.pem`, { type: 'application/x-pem-file' }); if (input) { await user.upload(input as HTMLInputElement, file); } await waitFor(() => { - const saveButton = scr.getByTestId('save-certificate-button'); - expect(saveButton).not.toBeDisabled(); - }); - - const saveButton = scr.getByTestId('save-certificate-button'); - await user.click(saveButton); - - await waitFor(() => { - expect(scr.queryByTestId(`certificate-dialog-${action.certType}`)).not.toBeInTheDocument(); + expect(scr.getByTestId(`${action.certType}-status`)).toBeInTheDocument(); }); break; }