diff --git a/ui/src/components/BaseFormView/BaseFormView.tsx b/ui/src/components/BaseFormView/BaseFormView.tsx index 5a49c27393..58e0fa0ed6 100644 --- a/ui/src/components/BaseFormView/BaseFormView.tsx +++ b/ui/src/components/BaseFormView/BaseFormView.tsx @@ -749,10 +749,14 @@ class BaseFormView extends PureComponent { const body = new URLSearchParams(); Object.keys(this.datadict).forEach((key) => { if (this.datadict[key] != null) { + const entity = this.entities?.find((x) => x?.field === key); + if ((entity as any)?.encrypted && this.datadict[key] === '******') { + return; + } // Custom logic for only sending file content in payload, not file name and file size. if ( typeof this.datadict[key] === 'object' && - this.entities?.find((x) => x?.field === key)?.type === 'file' + entity?.type === 'file' ) { const { fileContent } = this.datadict?.[key] as { fileContent: string }; body.append(key, fileContent); diff --git a/ui/src/components/BaseFormView/BaseFormViewUtils.ts b/ui/src/components/BaseFormView/BaseFormViewUtils.ts index e3a4fce849..88b51a62fe 100644 --- a/ui/src/components/BaseFormView/BaseFormViewUtils.ts +++ b/ui/src/components/BaseFormView/BaseFormViewUtils.ts @@ -58,7 +58,10 @@ export const mapEntityIntoBaseForViewEntityObject = ( if (props.mode === MODE_EDIT) { tempEntity.value = typeof currentInput?.[e.field] !== 'undefined' ? currentInput?.[e.field] : null; - tempEntity.value = e.encrypted ? '' : tempEntity.value; + // For encrypted fields, preserve masked values (like "******") to show user that value exists + if (e.encrypted && !currentInput?.[e.field]) { + tempEntity.value = ''; + } tempEntity.display = typeof e?.options?.display !== 'undefined' ? e.options.display : true; if (oauthType) { @@ -111,7 +114,10 @@ export const mapEntityIntoBaseForViewEntityObject = ( typeof currentInput?.[e.field] !== 'undefined' ? currentInput?.[e.field] : e.defaultValue; - tempEntity.value = e.encrypted ? '' : tempEntity.value; + // For encrypted fields, preserve masked values (like "******") to show user that value exists + if (e.encrypted && !currentInput?.[e.field]) { + tempEntity.value = ''; + } tempEntity.display = typeof e?.options?.display !== 'undefined' ? e.options.display : true; if (oauthType) { diff --git a/ui/src/components/BaseFormView/tests/BaseFormViewUtils.test.ts b/ui/src/components/BaseFormView/tests/BaseFormViewUtils.test.ts new file mode 100644 index 0000000000..667b248386 --- /dev/null +++ b/ui/src/components/BaseFormView/tests/BaseFormViewUtils.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { mapEntityIntoBaseForViewEntityObject } from '../BaseFormViewUtils'; +import { MODE_EDIT, MODE_CONFIG } from '../../../constants/modes'; +import { BaseFormProps } from '../../../types/components/BaseFormTypes'; + +describe('BaseFormViewUtils - Password Placeholder Fix', () => { + const mockProps: BaseFormProps = { + mode: MODE_EDIT, + currentServiceState: {}, + serviceName: 'account', + page: 'configuration', + stanzaName: 'test', + handleFormSubmit: () => {}, + }; + + it('should preserve masked password values in edit mode', () => { + const entity = { + field: 'password', + label: 'Password', + type: 'text' as const, + encrypted: true, + }; + + const currentInput = { + password: '******', + }; + + const result = mapEntityIntoBaseForViewEntityObject( + entity, + currentInput, + mockProps + ); + + expect(result.value).toBe('******'); + }); + + it('should show empty string for encrypted fields with no value in edit mode', () => { + const entity = { + field: 'password', + label: 'Password', + type: 'text' as const, + encrypted: true, + }; + + const currentInput = {}; + + const result = mapEntityIntoBaseForViewEntityObject( + entity, + currentInput, + mockProps + ); + + expect(result.value).toBe(''); + }); + + it('should show empty string for encrypted fields with empty value in edit mode', () => { + const entity = { + field: 'password', + label: 'Password', + type: 'text' as const, + encrypted: true, + }; + + const currentInput = { + password: '', + }; + + const result = mapEntityIntoBaseForViewEntityObject( + entity, + currentInput, + mockProps + ); + + expect(result.value).toBe(''); + }); + + it('should preserve non-encrypted field values in edit mode', () => { + const entity = { + field: 'username', + label: 'Username', + type: 'text' as const, + encrypted: false, + }; + + const currentInput = { + username: 'testuser', + }; + + const result = mapEntityIntoBaseForViewEntityObject( + entity, + currentInput, + mockProps + ); + + expect(result.value).toBe('testuser'); + }); + + it('should work the same way in config mode', () => { + const configProps: BaseFormProps = { ...mockProps, mode: MODE_CONFIG }; + + const entity = { + field: 'password', + label: 'Password', + type: 'text' as const, + encrypted: true, + }; + + const currentInput = { + password: '******', + }; + + const result = mapEntityIntoBaseForViewEntityObject( + entity, + currentInput, + configProps + ); + + expect(result.value).toBe('******'); + }); + + it('should not submit masked password values', () => { + const entity = { + field: 'password', + label: 'Password', + type: 'text' as const, + encrypted: true, + }; + + const mockEntities = [entity]; + const mockDataDict: Record = { password: '******' }; + + const mockComponent = { + entities: mockEntities, + datadict: mockDataDict, + }; + + const body = new URLSearchParams(); + Object.keys(mockComponent.datadict).forEach((key) => { + if (mockComponent.datadict[key] != null) { + const foundEntity = mockComponent.entities?.find((x: any) => x?.field === key); + if (foundEntity?.encrypted && mockComponent.datadict[key] === '******') { + return; + } + body.append(key, String(mockComponent.datadict[key])); + } + }); + + expect(body.has('password')).toBe(false); + }); +}); diff --git a/ui/src/components/TextComponent/TextComponent.tsx b/ui/src/components/TextComponent/TextComponent.tsx index 6797055efe..83a42dfc12 100755 --- a/ui/src/components/TextComponent/TextComponent.tsx +++ b/ui/src/components/TextComponent/TextComponent.tsx @@ -15,10 +15,26 @@ export interface TextComponentProps { } class TextComponent extends Component { + private wasMasked: boolean = false; + handleChange = (e: unknown, { value }: { value: string | number }) => { this.props.handleChange(this.props.field, value); }; + handleFocus = (e: React.FocusEvent) => { + if (this.props.encrypted && e.target.value === '******') { + this.wasMasked = true; + this.props.handleChange(this.props.field, ''); + } + }; + + handleBlur = (e: React.FocusEvent) => { + if (this.props.encrypted && e.target.value === '' && this.wasMasked) { + this.props.handleChange(this.props.field, '******'); + this.wasMasked = false; + } + }; + render() { const { id, field, disabled, value, encrypted, ...restProps } = this.props; const restSuiProps = excludeControlWrapperProps(restProps); @@ -30,6 +46,8 @@ class TextComponent extends Component { disabled={disabled && 'dimmed'} value={value === null || typeof value === 'undefined' ? '' : value.toString()} onChange={this.handleChange} + onFocus={this.handleFocus} + onBlur={this.handleBlur} type={encrypted ? 'password' : 'text'} /> );