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

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 76f24e3e08f Add password field type to FlexibleForm (#70473)
76f24e3e08f is described below

commit 76f24e3e08f07cea9884b267d4f0ea94579e45f7
Author: Edward Shen <[email protected]>
AuthorDate: Fri Aug 14 03:52:01 2026 +0800

    Add password field type to FlexibleForm (#70473)
    
    * Add password field type to FlexibleForm
    
    * Add tests for FieldPassword
    
    * Remove stray indentation in FieldPassword header
    
    * Add blank line after isFieldPassword definition
    
    * Add show/hide toggle to FieldPassword
    
    * Add test for FieldPassword show/hide toggle
    
    * Add autoComplete to FieldPassword input
    
    Co-authored-by: Brent Bovenzi <[email protected]>
    
    * Extract password toggle into a shared component
    
    * Fix translation namespace in PasswordToggle
    
    * Address review comments
    
    * Use InputGroup for password toggle
    
    * Update 
airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldPassword.tsx
    
    ---------
    
    Co-authored-by: Brent Bovenzi <[email protected]>
---
 .../ui/public/i18n/locales/en/components.json      |   4 +
 .../components/FlexibleForm/FieldPassword.test.tsx | 115 +++++++++++++++++++++
 .../src/components/FlexibleForm/FieldPassword.tsx  |  61 +++++++++++
 .../src/components/FlexibleForm/FieldSelector.tsx  |   6 ++
 .../airflow/ui/src/components/PasswordToggle.tsx   |  43 ++++++++
 .../pages/Connections/ConnectionStandardFields.tsx |  33 +++---
 6 files changed, 243 insertions(+), 19 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
index 08490d89f8d..4da7700ed14 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
@@ -119,6 +119,10 @@
     "file": "File",
     "location": "line {{line}} in {{name}}"
   },
+  "passwordToggle": {
+    "hide": "Hide value",
+    "show": "Show value"
+  },
   "reparseDag": "Reparse Dag",
   "slowestTaskInstances": {
     "empty": "No completed task instances in this range",
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldPassword.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldPassword.test.tsx
new file mode 100644
index 00000000000..6d28e563747
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldPassword.test.tsx
@@ -0,0 +1,115 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { fireEvent, render } from "@testing-library/react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { FieldPassword } from "./FieldPassword";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const mockParamsDict: Record<string, any> = {};
+const mockSetParamsDict = vi.fn();
+
+vi.mock("src/queries/useParamStore", () => ({
+  paramPlaceholder: {
+    schema: {},
+    value: null,
+  },
+  useParamStore: () => ({
+    disabled: false,
+    paramsDict: mockParamsDict,
+    setParamsDict: mockSetParamsDict,
+  }),
+}));
+
+const getInputByName = (name: string) =>
+  document.querySelector<HTMLInputElement>(`#element_${name}`) as 
HTMLInputElement;
+
+describe("FieldPassword", () => {
+  beforeEach(() => {
+    mockSetParamsDict.mockClear();
+    Object.keys(mockParamsDict).forEach((key) => {
+      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+      delete mockParamsDict[key];
+    });
+  });
+
+  it("renders a masked input for a password-format field", () => {
+    mockParamsDict.private_key = {
+      schema: { format: "password", type: "string" },
+      value: null,
+    };
+
+    render(<FieldPassword name="private_key" onUpdate={vi.fn()} />, { wrapper: 
Wrapper });
+
+    expect(getInputByName("private_key").type).toBe("password");
+  });
+
+  it("stores the typed value in the param store", () => {
+    mockParamsDict.private_key = {
+      schema: { format: "password", type: "string" },
+      value: null,
+    };
+    const onUpdate = vi.fn();
+
+    render(<FieldPassword name="private_key" onUpdate={onUpdate} />, { 
wrapper: Wrapper });
+
+    fireEvent.change(getInputByName("private_key"), { target: { value: 
"s3cret" } });
+
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+    expect(mockParamsDict.private_key.value).toBe("s3cret");
+    expect(onUpdate).toHaveBeenLastCalledWith("s3cret");
+  });
+
+  it("clears the param to null when the input is emptied", () => {
+    mockParamsDict.private_key = {
+      schema: { format: "password", type: "string" },
+      value: "s3cret",
+    };
+    const onUpdate = vi.fn();
+
+    render(<FieldPassword name="private_key" onUpdate={onUpdate} />, { 
wrapper: Wrapper });
+
+    fireEvent.change(getInputByName("private_key"), { target: { value: "" } });
+
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+    expect(mockParamsDict.private_key.value).toBeNull();
+    expect(onUpdate).toHaveBeenLastCalledWith("");
+  });
+  it("toggles between masked and plain text when the button is clicked", () => 
{
+    mockParamsDict.private_key = {
+      schema: { format: "password", type: "string" },
+      value: "s3cret",
+    };
+
+    render(<FieldPassword name="private_key" onUpdate={vi.fn()} />, { wrapper: 
Wrapper });
+
+    const input = getInputByName("private_key");
+    const toggle = document.querySelector("button") as HTMLButtonElement;
+
+    expect(input.type).toBe("password");
+
+    fireEvent.click(toggle);
+    expect(input.type).toBe("text");
+
+    fireEvent.click(toggle);
+    expect(input.type).toBe("password");
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldPassword.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldPassword.tsx
new file mode 100644
index 00000000000..d51d1a78c7c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldPassword.tsx
@@ -0,0 +1,61 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Input, InputGroup } from "@chakra-ui/react";
+import { useState } from "react";
+
+import { PasswordToggle } from "src/components/PasswordToggle";
+import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
+
+import type { FlexibleFormElementProps } from ".";
+
+export const FieldPassword = ({ name, namespace = "default", onUpdate }: 
FlexibleFormElementProps) => {
+  const [showPassword, setShowPassword] = useState(false);
+  const { disabled, paramsDict, setParamsDict } = useParamStore(namespace);
+  const param = paramsDict[name] ?? paramPlaceholder;
+  const handleChange = (value: string) => {
+    if (paramsDict[name]) {
+      // "undefined" values are removed from params, so we set it to null to 
avoid falling back to DAG defaults.
+      paramsDict[name].value = value === "" ? null : value;
+    }
+
+    setParamsDict(paramsDict);
+    onUpdate(value);
+  };
+
+  return (
+    <InputGroup
+      endElement={<PasswordToggle isVisible={showPassword} onToggle={() => 
setShowPassword(!showPassword)} />}
+    >
+      <Input
+        autoComplete="new-password"
+        disabled={disabled}
+        id={`element_${name}`}
+        maxLength={param.schema.maxLength ?? undefined}
+        minLength={param.schema.minLength ?? undefined}
+        name={`element_${name}`}
+        onChange={(event) => {
+          handleChange(event.target.value);
+        }}
+        size="sm"
+        type={showPassword ? "text" : "password"}
+        value={(param.value ?? "") as string}
+      />
+    </InputGroup>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
index 173289a06d8..d88016dcf0d 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
@@ -30,6 +30,7 @@ import { FieldMultiType } from "./FieldMultiType";
 import { FieldMultilineText } from "./FieldMultilineText";
 import { FieldNumber } from "./FieldNumber";
 import { FieldObject } from "./FieldObject";
+import { FieldPassword } from "./FieldPassword";
 import { FieldString } from "./FieldString";
 import { FieldStringArray } from "./FieldStringArray";
 
@@ -90,6 +91,9 @@ const isFieldNumber = (fieldType: string) => {
 
 const isFieldObject = (fieldType: string) => fieldType === "object";
 
+const isFieldPassword = (fieldType: string, fieldSchema: ParamSchema) =>
+  fieldType === "string" && fieldSchema.format === "password";
+
 const isFieldStringArray = (fieldType: string, fieldSchema: ParamSchema) =>
   fieldType === "array" && (fieldSchema.items?.type === undefined || 
fieldSchema.items.type === "string");
 
@@ -148,6 +152,8 @@ export const FieldSelector = ({ name, namespace = 
"default", onUpdate }: Flexibl
     return <FieldDuration name={name} namespace={namespace} 
onUpdate={onUpdate} />;
   } else if (isFieldMultilineText(fieldType, param.schema)) {
     return <FieldMultilineText name={name} namespace={namespace} 
onUpdate={onUpdate} />;
+  } else if (isFieldPassword(fieldType, param.schema)) {
+    return <FieldPassword name={name} namespace={namespace} 
onUpdate={onUpdate} />;
   } else {
     return <FieldString name={name} namespace={namespace} onUpdate={onUpdate} 
/>;
   }
diff --git a/airflow-core/src/airflow/ui/src/components/PasswordToggle.tsx 
b/airflow-core/src/airflow/ui/src/components/PasswordToggle.tsx
new file mode 100644
index 00000000000..d48e55cb914
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/PasswordToggle.tsx
@@ -0,0 +1,43 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { IconButtonProps } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiEye, FiEyeOff } from "react-icons/fi";
+
+import { IconButton } from "src/components/ui";
+
+type Props = {
+  readonly isVisible: boolean;
+  readonly onToggle: () => void;
+} & IconButtonProps;
+
+export const PasswordToggle = ({ isVisible, onToggle, ...rest }: Props) => {
+  const { t: translate } = useTranslation("components");
+
+  return (
+    <IconButton
+      label={isVisible ? translate("passwordToggle.hide") : 
translate("passwordToggle.show")}
+      onClick={onToggle}
+      size="xs"
+      {...rest}
+    >
+      {isVisible ? <FiEye size={15} /> : <FiEyeOff size={15} />}
+    </IconButton>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionStandardFields.tsx
 
b/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionStandardFields.tsx
index 0a47bdc450b..e1af3f45a2e 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionStandardFields.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionStandardFields.tsx
@@ -16,11 +16,11 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Field, Stack, Textarea, Input } from "@chakra-ui/react";
+import { Field, Stack, Textarea, Input, InputGroup } from "@chakra-ui/react";
 import { useState } from "react";
 import { type Control, Controller } from "react-hook-form";
-import { FiEye, FiEyeOff } from "react-icons/fi";
 
+import { PasswordToggle } from "src/components/PasswordToggle";
 import type { StandardFieldSpec } from "src/queries/useConnectionTypeMeta";
 
 import type { ConnectionBody } from "./Connections";
@@ -56,9 +56,19 @@ const StandardFields = ({ control, standardFields }: 
StandardFieldsProps) => {
                   {key === "description" ? (
                     <Textarea {...field} placeholder={fields.placeholder ?? 
""} />
                   ) : (
-                    <div style={{ position: "relative", width: "100%" }}>
+                    <InputGroup
+                      endElement={
+                        key === "password" ? (
+                          <PasswordToggle
+                            isVisible={showPassword}
+                            onToggle={() => setShowPassword(!showPassword)}
+                          />
+                        ) : undefined
+                      }
+                    >
                       <Input
                         {...field}
+                        autoComplete={key === "password" ? "new-password" : 
undefined}
                         placeholder={fields.placeholder ?? ""}
                         type={
                           key === "password" && !showPassword
@@ -68,22 +78,7 @@ const StandardFields = ({ control, standardFields }: 
StandardFieldsProps) => {
                               : "text"
                         }
                       />
-                      {key === "password" && (
-                        <button
-                          onClick={() => setShowPassword(!showPassword)}
-                          style={{
-                            cursor: "pointer",
-                            position: "absolute",
-                            right: "10px",
-                            top: "50%",
-                            transform: "translateY(-50%)",
-                          }}
-                          type="button"
-                        >
-                          {showPassword ? <FiEye size={15} /> : <FiEyeOff 
size={15} />}
-                        </button>
-                      )}
-                    </div>
+                    </InputGroup>
                   )}
                 </Stack>
               </Field.Root>

Reply via email to