This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 0ca51d9796a fix(Database Connection Form): Added validation to db 
credential input (#37919)
0ca51d9796a is described below

commit 0ca51d9796ab5246f18853da96eab7dc86b7f3ec
Author: isaac-jaynes-imperva <[email protected]>
AuthorDate: Sun Jul 12 09:36:41 2026 -0700

    fix(Database Connection Form): Added validation to db credential input 
(#37919)
    
    Co-authored-by: codeant-ai-for-open-source[bot] 
<244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
    Co-authored-by: Evan Rusackas <[email protected]>
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../src/components/Form/LabeledErrorBoundInput.tsx |   8 ++
 .../superset-ui-core/src/components/Form/types.ts  |   5 +
 .../DatabaseConnectionForm/EncryptedField.test.tsx | 142 ++++++++++++++++++++-
 .../DatabaseConnectionForm/EncryptedField.tsx      |  73 ++++++++---
 .../src/features/databases/DatabaseModal/styles.ts |  31 ++---
 5 files changed, 224 insertions(+), 35 deletions(-)

diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx
 
b/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx
index 2095cca2f03..cfe59254f9f 100644
--- 
a/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx
@@ -28,6 +28,10 @@ const StyledInput = styled(Input)`
   margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`};
 `;
 
+const StyledTextArea = styled(Input.TextArea)`
+  margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`};
+`;
+
 const StyledInputPassword = styled(Input.Password)`
   margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`};
 `;
@@ -62,6 +66,8 @@ export const LabeledErrorBoundInput = ({
   get_url,
   description,
   isValidating = false,
+  renderAsTextArea,
+  textAreaCss,
   ...props
 }: LabeledErrorBoundInputProps) => {
   const hasError = !!errorMessage;
@@ -98,6 +104,8 @@ export const LabeledErrorBoundInput = ({
             }
             role="textbox"
           />
+        ) : renderAsTextArea ? (
+          <StyledTextArea css={textAreaCss} {...props} {...validationMethods} 
/>
         ) : (
           <StyledInput {...props} {...validationMethods} />
         )}
diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/Form/types.ts 
b/superset-frontend/packages/superset-ui-core/src/components/Form/types.ts
index ffff65dc35a..bcf5db252bb 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/Form/types.ts
+++ b/superset-frontend/packages/superset-ui-core/src/components/Form/types.ts
@@ -16,7 +16,10 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import type { SerializedStyles } from '@emotion/react';
+
 export type { FormProps, FormInstance, FormItemProps } from 'antd/es/form';
+export type { SerializedStyles };
 
 export interface LabeledErrorBoundInputProps {
   label?: string;
@@ -31,5 +34,7 @@ export interface LabeledErrorBoundInputProps {
   classname?: string;
   visibilityToggle?: boolean;
   isValidating?: boolean;
+  renderAsTextArea?: boolean;
+  textAreaCss?: SerializedStyles;
   [x: string]: any;
 }
diff --git 
a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.test.tsx
 
b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.test.tsx
index 2215f0ef0e7..bf067bbbe17 100644
--- 
a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.test.tsx
+++ 
b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.test.tsx
@@ -17,7 +17,12 @@
  * under the License.
  */
 
-import { render, fireEvent, screen } from 'spec/helpers/testing-library';
+import {
+  render,
+  fireEvent,
+  screen,
+  waitFor,
+} from 'spec/helpers/testing-library';
 import { DatabaseObject, ConfigurationMethod } from '../../types';
 import { EncryptedField, encryptedCredentialsMap } from './EncryptedField';
 
@@ -324,6 +329,110 @@ describe('EncryptedField', () => {
       expect(screen.getByRole('textbox')).toBeInTheDocument();
       expect(screen.queryByText('Upload credentials')).not.toBeInTheDocument();
     });
+
+    test('uploading a file does not validate stale state, and validates once 
the parent commits the new value', async () => {
+      const changeMethods = createMockChangeMethods();
+      const getValidation = jest.fn();
+      const fileContent = '{"type": "service_account"}';
+
+      const { rerender } = render(
+        <EncryptedField
+          {...defaultProps}
+          changeMethods={changeMethods}
+          getValidation={getValidation}
+          db={createMockDb('bigquery', { credentials_info: '' })}
+        />,
+      );
+
+      const file = new File([fileContent], 'creds.json', {
+        type: 'application/json',
+      });
+      const input = document.querySelector(
+        'input[type="file"]',
+      ) as HTMLInputElement;
+
+      fireEvent.change(input, { target: { files: [file] } });
+
+      await waitFor(() =>
+        expect(changeMethods.onParametersChange).toHaveBeenCalledWith(
+          expect.objectContaining({
+            target: expect.objectContaining({ value: fileContent }),
+          }),
+        ),
+      );
+
+      // The parent hasn't re-rendered with the committed value yet (in the
+      // real app this is the reducer-dispatch/render gap) — validation must
+      // not fire against the stale `db` in the meantime.
+      expect(getValidation).not.toHaveBeenCalled();
+
+      // Once the parent commits the update and re-renders with the new
+      // `db.parameters.credentials_info`, validation should fire exactly
+      // once, against the up-to-date value.
+      rerender(
+        <EncryptedField
+          {...defaultProps}
+          changeMethods={changeMethods}
+          getValidation={getValidation}
+          db={createMockDb('bigquery', { credentials_info: fileContent })}
+        />,
+      );
+
+      await waitFor(() => expect(getValidation).toHaveBeenCalledTimes(1));
+    });
+
+    test('removing an upload before the parent commits clears the pending 
validation', async () => {
+      const changeMethods = createMockChangeMethods();
+      const getValidation = jest.fn();
+      const fileContent = '{"type": "service_account"}';
+
+      const { container, rerender } = render(
+        <EncryptedField
+          {...defaultProps}
+          changeMethods={changeMethods}
+          getValidation={getValidation}
+          db={createMockDb('bigquery', { credentials_info: '' })}
+        />,
+      );
+
+      const file = new File([fileContent], 'creds.json', {
+        type: 'application/json',
+      });
+      const input = document.querySelector(
+        'input[type="file"]',
+      ) as HTMLInputElement;
+      fireEvent.change(input, { target: { files: [file] } });
+
+      await waitFor(() =>
+        expect(changeMethods.onParametersChange).toHaveBeenCalledWith(
+          expect.objectContaining({
+            target: expect.objectContaining({ value: fileContent }),
+          }),
+        ),
+      );
+
+      // Remove the upload before the parent has committed/re-rendered with
+      // the uploaded content — this must clear the pending validation, not
+      // just leave it dangling.
+      const removeButton = container.querySelector(
+        '[aria-label="delete"]',
+      ) as HTMLElement;
+      fireEvent.click(removeButton);
+
+      // Even if some later, unrelated render happens to carry the same
+      // content the removed upload had (e.g. stale props echoed back), the
+      // cleared pending state must not fire a validation for it.
+      rerender(
+        <EncryptedField
+          {...defaultProps}
+          changeMethods={changeMethods}
+          getValidation={getValidation}
+          db={createMockDb('bigquery', { credentials_info: fileContent })}
+        />,
+      );
+
+      expect(getValidation).not.toHaveBeenCalled();
+    });
   });
 
   // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from 
describe blocks
@@ -373,6 +482,37 @@ describe('EncryptedField', () => {
     });
   });
 
+  // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from 
describe blocks
+  test('renderAsTextArea branch: renders a textarea element (not a plain 
input) in edit mode', () => {
+    const props = { ...defaultProps, isEditMode: true };
+    const { container } = render(<EncryptedField {...props} />);
+
+    // renderAsTextArea causes LabeledErrorBoundInput to render <textarea>
+    expect(container.querySelector('textarea')).toBeInTheDocument();
+    expect(container.querySelector('input')).not.toBeInTheDocument();
+  });
+
+  test('renderAsTextArea branch: renders a textarea element in copy-paste 
mode', () => {
+    const props = { ...defaultProps, isEditMode: false };
+    render(<EncryptedField {...props} />);
+
+    // Switch to copy-paste option to trigger renderAsTextArea branch
+    const select = screen.getByRole('combobox');
+    fireEvent.mouseDown(select);
+    fireEvent.click(screen.getByText('Copy and Paste JSON credentials'));
+
+    expect(screen.getByRole('textbox').tagName).toBe('TEXTAREA');
+  });
+
+  test('renderAsTextArea branch: does not render a textarea when upload option 
is selected', () => {
+    const props = { ...defaultProps, isEditMode: false, editNewDb: false };
+    render(<EncryptedField {...props} />);
+
+    // Default upload option — no textarea should be present
+    expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+    expect(screen.getByText('Upload credentials')).toBeInTheDocument();
+  });
+
   // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from 
describe blocks
   describe('Error Boundaries', () => {
     test('renders gracefully when database prop is missing', () => {
diff --git 
a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx
 
b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx
index 5c1eec0a302..f1dddf76223 100644
--- 
a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx
+++ 
b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx
@@ -18,19 +18,23 @@
  */
 import { useState, useEffect } from 'react';
 import { t } from '@apache-superset/core/translation';
-import { SupersetTheme } from '@apache-superset/core/theme';
+import { SupersetTheme, useTheme } from '@apache-superset/core/theme';
 import {
-  Input,
   Button,
   FormLabel,
   Select,
   Upload,
   type UploadFile,
+  LabeledErrorBoundInput as ValidatedInput,
 } from '@superset-ui/core/components';
 import { Icons } from '@superset-ui/core/components/Icons';
 import { useToasts } from 'src/components/MessageToasts/withToasts';
 import { DatabaseParameters, Engines, FieldPropTypes } from '../../types';
-import { infoTooltip, CredentialInfoForm } from '../styles';
+import {
+  infoTooltip,
+  CredentialInfoForm,
+  CredentialInfoFormTextArea,
+} from '../styles';
 
 enum CredentialInfoOptions {
   JsonUpload,
@@ -53,11 +57,24 @@ export const EncryptedField = ({
   editNewDb,
   isPublic = true,
   setIsPublic,
+  isValidating,
+  validationErrors,
+  getValidation,
 }: FieldPropTypes) => {
+  const theme = useTheme() as SupersetTheme;
+  const credentialTextAreaCss = CredentialInfoFormTextArea(theme);
   const [fileList, setFileList] = useState<UploadFile[]>([]);
   const [uploadOption, setUploadOption] = useState<number>(
     CredentialInfoOptions.JsonUpload.valueOf(),
   );
+  // `getValidation` closes over the parent's `db` state as of the last
+  // render, so calling it synchronously right after `onParametersChange`
+  // (which just dispatches a state update) would validate the *previous*
+  // credential value. Instead, remember the uploaded value and validate
+  // once the parameter prop actually reflects it.
+  const [pendingValidationValue, setPendingValidationValue] = useState<
+    string | null
+  >(null);
   const { addDangerToast } = useToasts();
   const isGSheets = db?.engine === Engines.GSheet;
   const showCredentialsInfo = !isEditMode && (!isGSheets || !isPublic);
@@ -123,6 +140,16 @@ export const EncryptedField = ({
     });
   }, []);
 
+  useEffect(() => {
+    if (
+      pendingValidationValue !== null &&
+      paramValue === pendingValidationValue
+    ) {
+      setPendingValidationValue(null);
+      getValidation();
+    }
+  }, [paramValue, pendingValidationValue, getValidation]);
+
   return (
     <CredentialInfoForm>
       {isGSheets && (
@@ -168,22 +195,27 @@ export const EncryptedField = ({
       (uploadOption === CredentialInfoOptions.CopyPaste ||
         isEditMode ||
         editNewDb) ? (
-        <div className="input-container">
-          <FormLabel>{t('Service Account')}</FormLabel>
-          <Input.TextArea
-            className="input-form"
-            name={encryptedField}
-            value={
-              typeof encryptedValue === 'boolean'
-                ? String(encryptedValue)
-                : encryptedValue
-            }
-            onChange={changeMethods.onParametersChange}
-            placeholder={t(
-              'Paste content of service credentials JSON file here',
-            )}
-          />
-        </div>
+        <ValidatedInput
+          id={encryptedField}
+          name={encryptedField}
+          required={false}
+          isValidating={isValidating}
+          value={
+            typeof encryptedValue === 'boolean'
+              ? String(encryptedValue)
+              : encryptedValue
+          }
+          validationMethods={{ onBlur: getValidation }}
+          errorMessage={
+            encryptedField ? validationErrors?.[encryptedField] : null
+          }
+          placeholder={t('Paste content of service credentials JSON file 
here')}
+          label={t('Service Account')}
+          onChange={changeMethods.onParametersChange}
+          helpText={t('Add service credentials')}
+          renderAsTextArea
+          textAreaCss={credentialTextAreaCss}
+        />
       ) : (
         showCredentialsInfo && (
           <div
@@ -198,6 +230,7 @@ export const EncryptedField = ({
               beforeUpload={() => false}
               onRemove={() => {
                 setFileList([]);
+                setPendingValidationValue(null);
                 changeMethods.onParametersChange({
                   target: {
                     name: encryptedField,
@@ -220,6 +253,7 @@ export const EncryptedField = ({
                       },
                     });
                     setFileList(info.fileList);
+                    setPendingValidationValue(fileContent);
                   } catch {
                     setFileList([]);
                     addDangerToast(
@@ -229,6 +263,7 @@ export const EncryptedField = ({
                     );
                   }
                 } else {
+                  setPendingValidationValue(null);
                   changeMethods.onParametersChange({
                     target: {
                       name: encryptedField,
diff --git a/superset-frontend/src/features/databases/DatabaseModal/styles.ts 
b/superset-frontend/src/features/databases/DatabaseModal/styles.ts
index 6e065acebb6..59770c17810 100644
--- a/superset-frontend/src/features/databases/DatabaseModal/styles.ts
+++ b/superset-frontend/src/features/databases/DatabaseModal/styles.ts
@@ -383,6 +383,18 @@ export const EditHeaderSubtitle = styled.div`
   font-weight: ${({ theme }) => theme.fontWeightStrong};
 `;
 
+export const CredentialInfoFormTextArea = (theme: SupersetTheme) => css`
+  height: 100px;
+  width: 100%;
+  border: 1px solid ${theme.colorBorder};
+  border-radius: ${theme.borderRadius}px;
+  resize: vertical;
+  padding: ${theme.sizeUnit * 1.5}px ${theme.sizeUnit * 2}px;
+  &::placeholder {
+    color: ${theme.colorTextPlaceholder};
+  }
+`;
+
 export const CredentialInfoForm = styled.div`
   margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
 
@@ -413,19 +425,6 @@ export const CredentialInfoForm = styled.div`
     margin: ${({ theme }) => theme.sizeUnit * 4}px 0;
     display: flex;
     flex-direction: column;
-}
-  }
-  .input-form {
-    height: 100px;
-    width: 100%;
-    border: 1px solid ${({ theme }) => theme.colorBorder};
-    border-radius: ${({ theme }) => theme.borderRadius}px;
-    resize: vertical;
-    padding: ${({ theme }) => theme.sizeUnit * 1.5}px
-      ${({ theme }) => theme.sizeUnit * 2}px;
-    &::placeholder {
-      color: ${({ theme }) => theme.colorTextPlaceholder};
-    }
   }
 
   .input-container {
@@ -442,7 +441,8 @@ export const CredentialInfoForm = styled.div`
       width: fit-content;
     }
 
-    .credentials-uploaded-btn, .credentials-uploaded-remove {
+    .credentials-uploaded-btn,
+    .credentials-uploaded-remove {
       flex: 0 0 auto;
     }
 
@@ -450,7 +450,8 @@ export const CredentialInfoForm = styled.div`
     .input-upload {
       display: none !important;
     }
-  }`;
+  }
+`;
 
 export const SelectDatabaseStyles = styled.div`
   .preferred {

Reply via email to