From 6cdc525ddc4003c19998dbc4be38579d036bca23 Mon Sep 17 00:00:00 2001 From: Rogger Vasquez Date: Fri, 8 May 2026 14:15:34 -0700 Subject: [PATCH] feat(frontend): tweak shadow link create Connection step The TLS section used to surface an Upload/File-path mode picker and three cert fields the moment TLS was enabled, even though most users just want a plain encrypted link. The auth section also gave no clue that the SASL user has to live on the source cluster and carry specific ACLs. The new layout collapses CA and mTLS into separate disclosures so the cert mode picker only appears when one is open, and adds an inline info callout above the auth credentials pointing at the required ACLs. The cert upload modal is gone: dropping a file now commits straight to form state through the registry Dropzone, with a trash button in the corner for removal. Toggle copy shifts from SCRAM to SASL since the underlying mechanisms are SCRAM-SHA-256 and SCRAM-SHA-512. A small secret-reference helper was extracted so the SASL password and mTLS client key stop duplicating the secret template parsing. --- .../connection/bootstrap-servers.test.tsx | 79 +++ .../create/connection/certificate-dialog.tsx | 191 ------ .../create/connection/mtls-configuration.tsx | 612 ++++++++++-------- .../connection/scram-configuration.test.tsx | 16 +- .../create/connection/scram-configuration.tsx | 68 +- .../create/connection/secret-reference.ts | 16 + .../create/connection/tls-configuration.tsx | 20 +- .../create/shadowlink-create-page.test.tsx | 43 +- 8 files changed, 520 insertions(+), 525 deletions(-) delete mode 100644 frontend/src/components/pages/shadowlinks/create/connection/certificate-dialog.tsx create mode 100644 frontend/src/components/pages/shadowlinks/create/connection/secret-reference.ts 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; }