From b9703f04f1bfb129ab2d1989af64b8853ab154e5 Mon Sep 17 00:00:00 2001 From: Oksana Bazylieva Date: Thu, 10 Sep 2026 14:50:41 -0400 Subject: [PATCH 1/2] ACM-42605 Fix editing masked secret fields without revealing Signed-off-by: Oksana Bazylieva --- frontend/src/components/AcmDataForm.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/AcmDataForm.tsx b/frontend/src/components/AcmDataForm.tsx index bc6af464f08..bf90c0be412 100644 --- a/frontend/src/components/AcmDataForm.tsx +++ b/frontend/src/components/AcmDataForm.tsx @@ -1052,7 +1052,7 @@ export function AcmDataFormInput(props: { input: Input; validated?: 'error'; isR ) } case 'TextArea': { - const hideSecretInput = input.value !== '' && input.isSecret === true && !showSecrets + const hideSecretInput = input.isSecret === true && !showSecrets const { onChange, ...inputProps } = input return ( @@ -1060,7 +1060,6 @@ export function AcmDataFormInput(props: { input: Input; validated?: 'error'; isR onChange(value)} - value={'**************'} validated={validated} type={'password'} readOnlyVariant={isReadOnly ? 'default' : undefined} From fc7ddd31d9466093e86a8b39594049587b4ab381 Mon Sep 17 00:00:00 2001 From: Oksana Bazylieva Date: Fri, 11 Sep 2026 22:48:48 -0400 Subject: [PATCH 2/2] coderabbit review Signed-off-by: Oksana Bazylieva --- frontend/src/components/AcmDataForm.test.tsx | 63 +++++++++++++++++++- frontend/src/components/AcmDataForm.tsx | 50 +++++++++------- 2 files changed, 90 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/AcmDataForm.test.tsx b/frontend/src/components/AcmDataForm.test.tsx index eb4c8e526cf..9ebf4f01ed9 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 bf90c0be412..3cdfb75e3a4 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,29 +1059,22 @@ export function AcmDataFormInput(props: { input: Input; validated?: 'error'; isR ) } case 'TextArea': { - const hideSecretInput = 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)} - validated={validated} - type={'password'} - readOnlyVariant={isReadOnly ? 'default' : undefined} - /> - ) : ( -