diff --git a/frontend/src/components/AcmDataForm.test.tsx b/frontend/src/components/AcmDataForm.test.tsx
index eb4c8e526c..9ebf4f01ed 100644
--- a/frontend/src/components/AcmDataForm.test.tsx
+++ b/frontend/src/components/AcmDataForm.test.tsx
@@ -1,7 +1,13 @@
/* Copyright Contributors to the Open Cluster Management project */
+import { FormGroup } from '@patternfly/react-core'
+import { render } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'jest-axe'
+import { useState } from 'react'
import i18next from 'i18next'
const t = i18next.t.bind(i18next)
-import { generalValidationMessage, requiredValidationMessage } from './AcmDataForm'
+import { AcmDataFormInput, generalValidationMessage, requiredValidationMessage } from './AcmDataForm'
+import { Input } from './AcmFormData'
describe('ACMDataForm', () => {
describe('generalValidationMessage', () => {
@@ -15,4 +21,59 @@ describe('ACMDataForm', () => {
expect(requiredValidationMessage(t)).toEqual('You must fill out all required fields before you can proceed.')
})
})
+
+ describe('AcmDataFormInput masked secret TextArea', () => {
+ const multilineSecret = [
+ '-----BEGIN OPENSSH PRIVATE KEY-----',
+ 'abc123def456',
+ '-----END OPENSSH PRIVATE KEY-----',
+ ].join('\n')
+
+ function SecretHarness(props: { onValue: (value: string) => void }) {
+ const [value, setValue] = useState(multilineSecret)
+ const input: Input = {
+ id: 'ssh-privatekey',
+ type: 'TextArea',
+ label: 'SSH private key',
+ value,
+ isSecret: true,
+ onChange: (next: string) => {
+ setValue(next)
+ props.onValue(next)
+ },
+ }
+ // Mirror how the form renders inputs, so the field is labelled as it is in the real page.
+ return (
+
+
+
+ )
+ }
+
+ test('renders a hidden multiline secret in a textarea so line breaks survive editing', async () => {
+ const onValue = jest.fn()
+ const { container } = render()
+
+ // A hidden secret must remain a multiline textarea (not a single-line password input) so that
+ // typing into it preserves the value's newlines.
+ const field = container.querySelector('textarea')
+ expect(field).toBeInTheDocument()
+ expect(field).toHaveValue(multilineSecret)
+
+ // Append text while the field is still masked, without clicking the eyeball icon.
+ await userEvent.type(field!, ' # edited while hidden')
+
+ // The value handed to onChange keeps every original line break intact.
+ const lastValue = onValue.mock.calls[onValue.mock.calls.length - 1][0] as string
+ expect(lastValue.startsWith(multilineSecret)).toBe(true)
+ expect(lastValue.endsWith(' # edited while hidden')).toBe(true)
+ expect(lastValue.split('\n')).toEqual([
+ '-----BEGIN OPENSSH PRIVATE KEY-----',
+ 'abc123def456',
+ '-----END OPENSSH PRIVATE KEY----- # edited while hidden',
+ ])
+
+ expect(await axe(container)).toHaveNoViolations()
+ })
+ })
})
diff --git a/frontend/src/components/AcmDataForm.tsx b/frontend/src/components/AcmDataForm.tsx
index bc6af464f0..3cdfb75e3a 100644
--- a/frontend/src/components/AcmDataForm.tsx
+++ b/frontend/src/components/AcmDataForm.tsx
@@ -76,6 +76,7 @@ import {
TimesCircleIcon,
TrashIcon,
} from '@patternfly/react-icons'
+import { css } from '@emotion/css'
import useResizeObserver from '@react-hook/resize-observer'
import { Schema } from 'ajv'
import { Fragment, ReactElement, ReactNode, useCallback, useContext, useRef, useState } from 'react'
@@ -97,6 +98,12 @@ import { AcmSelectBase, AcmSelectBaseProps, SelectOptionObject, SelectVariant }
import { LostChangesContext, LostChangesPrompt } from './LostChanges'
import { SyncEditor, ValidationStatus } from './SyncEditor/SyncEditor'
+// Masks the characters of a multiline secret TextArea while preserving its real (newline-containing) value.
+const maskedSecretTextArea = css`
+ -webkit-text-security: disc;
+ text-security: disc;
+`
+
export interface AcmDataFormProps {
formData: FormData
editorTitle?: string
@@ -1052,30 +1059,22 @@ export function AcmDataFormInput(props: { input: Input; validated?: 'error'; isR
)
}
case 'TextArea': {
- const hideSecretInput = input.value !== '' && input.isSecret === true && !showSecrets
+ // Mask secret values with CSS rather than swapping in a single-line password input, so that
+ // multiline secrets (e.g. SSH private keys) keep their line breaks while being edited.
+ const maskSecret = input.isSecret === true && !showSecrets
const { onChange, ...inputProps } = input
return (
- {hideSecretInput ? (
- onChange(value)}
- value={'**************'}
- validated={validated}
- type={'password'}
- readOnlyVariant={isReadOnly ? 'default' : undefined}
- />
- ) : (
-