diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
index e8735ea2713c0..fc4f94675ba81 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
@@ -30,6 +30,7 @@
"firstConfirmMessage_other": "You are about to delete the following connections:",
"title": "Delete $t(connections.connection_one)"
},
+ "duplicate": "Duplicate $t(connections.connection_one)",
"edit": "Edit $t(connections.connection_one)",
"form": {
"connectionIdRequired": "Connection ID is required",
@@ -38,6 +39,7 @@
"connectionTypeRequired": "Connection Type is required",
"extraFieldsJson": "Extra Fields JSON",
"helperText": "Connection type missing? Make sure you have installed the corresponding Airflow Providers Package.",
+ "helperTextForDuplicatedRedactedFields": "Redacted fields ('***') are not copied. Fill them in for the new connection.",
"helperTextForRedactedFields": "Redacted fields ('***') will remain unchanged if not modified.",
"selectConnectionType": "Select Connection Type",
"standardFields": "Standard Fields"
diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
index 3d0f199c575ad..ee19f8c0001d6 100644
--- a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
@@ -52,6 +52,7 @@ import { formatNumber, useDocumentTitle } from "src/utils";
import AddConnectionButton from "./AddConnectionButton";
import DeleteConnectionButton from "./DeleteConnectionButton";
import DeleteConnectionsButton from "./DeleteConnectionsButton";
+import DuplicateConnectionButton from "./DuplicateConnectionButton";
import EditConnectionButton from "./EditConnectionButton";
import { NothingFoundInfo } from "./NothingFoundInfo";
import TestConnectionButton from "./TestConnectionButton";
@@ -125,6 +126,7 @@ const getColumns = ({
+
),
diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.test.tsx b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.test.tsx
new file mode 100644
index 0000000000000..61ca4e0c75f33
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.test.tsx
@@ -0,0 +1,114 @@
+/*!
+ * 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 "@testing-library/jest-dom/vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ConnectionResponse } from "openapi/requests/types.gen";
+
+import i18n from "src/i18n/config";
+import { Wrapper } from "src/utils/Wrapper";
+
+import adminLocale from "../../../public/i18n/locales/en/admin.json";
+import DuplicateConnectionButton from "./DuplicateConnectionButton";
+
+const { mockAddConnection } = vi.hoisted(() => ({ mockAddConnection: vi.fn() }));
+
+vi.mock("src/queries/useAddConnection", () => ({
+ useAddConnection: () => ({ addConnection: mockAddConnection, error: undefined, isPending: false }),
+}));
+
+vi.mock("src/queries/useConnectionTypeMeta", () => ({
+ useConnectionTypeMeta: () => ({
+ formattedData: {},
+ hookNames: { postgres: "Postgres" },
+ isPending: false,
+ keysList: ["postgres"],
+ }),
+}));
+
+vi.mock("src/queries/useConfig.tsx", () => ({
+ useConfig: () => false,
+}));
+
+const baseConnection: ConnectionResponse = {
+ conn_type: "postgres",
+ connection_id: "prod_db",
+ description: "Production",
+ extra: null,
+ host: "db.example.com",
+ login: "admin",
+ password: null,
+ port: 5432,
+ schema: "public",
+ team_name: null,
+};
+
+const duplicate = async (connection: ConnectionResponse) => {
+ render(, { wrapper: Wrapper });
+ fireEvent.click(screen.getByRole("button", { name: "Duplicate Connection" }));
+
+ const connectionIdInput = await screen.findByRole("textbox", { name: /Connection ID/u });
+
+ fireEvent.change(connectionIdInput, { target: { value: "staging_db" } });
+ fireEvent.blur(connectionIdInput);
+ const save = screen.getByRole("button", { name: /Save/u });
+
+ await waitFor(() => expect(save).toBeEnabled());
+ fireEvent.click(save);
+
+ await waitFor(() => expect(mockAddConnection).toHaveBeenCalledOnce());
+
+ return mockAddConnection.mock.calls[0]?.[0] as Record;
+};
+
+describe("DuplicateConnectionButton", () => {
+ beforeEach(() => {
+ i18n.addResourceBundle("en", "admin", adminLocale, true, true);
+ mockAddConnection.mockClear();
+ });
+
+ it("creates a connection under the new id with the copied values", async () => {
+ const body = await duplicate(baseConnection);
+
+ expect(body).toMatchObject({
+ conn_type: "postgres",
+ connection_id: "staging_db",
+ description: "Production",
+ host: "db.example.com",
+ login: "admin",
+ password: "",
+ port: "5432",
+ schema: "public",
+ });
+ expect(screen.queryByText(/are not copied/u)).not.toBeInTheDocument();
+ });
+
+ it("does not copy redacted values", async () => {
+ const body = await duplicate({
+ ...baseConnection,
+ extra: JSON.stringify({ nested: { api_key: "***", keep: "x" }, region: "eu", token: "***" }),
+ password: "***",
+ });
+
+ expect(body.password).toBe("");
+ expect(JSON.parse(body.extra ?? "")).toEqual({ nested: { keep: "x" }, region: "eu" });
+ expect(screen.getByText(/are not copied/u)).toBeInTheDocument();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx
new file mode 100644
index 0000000000000..e490acecd02d2
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx
@@ -0,0 +1,101 @@
+/*!
+ * 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 { useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiCopy } from "react-icons/fi";
+
+import type { ConnectionResponse } from "openapi/requests/types.gen";
+
+import { Alert, IconButton, Modal } from "src/system-components";
+
+import { useAddConnection } from "src/queries/useAddConnection";
+
+import ConnectionForm from "./ConnectionForm";
+import type { ConnectionBody } from "./Connections";
+
+// The API returns sensitive values as this placeholder. Editing merges it back into the stored secret,
+// but a new connection has no stored secret, so the placeholder must not be submitted as a value.
+const REDACTED = "***";
+
+type Props = {
+ readonly connection: ConnectionResponse;
+ readonly disabled: boolean;
+};
+
+const DuplicateConnectionButton = ({ connection, disabled }: Props) => {
+ const { t: translate } = useTranslation("admin");
+ const { onClose, onOpen, open } = useDisclosure();
+ const { addConnection, error, isPending } = useAddConnection({ onSuccessConfirm: onClose });
+
+ let hasRedactedValues = connection.password === REDACTED;
+ const extra =
+ connection.extra === "" || connection.extra === null
+ ? "{}"
+ : JSON.stringify(
+ JSON.parse(connection.extra, (_key, value: unknown) => {
+ if (value === REDACTED) {
+ hasRedactedValues = true;
+
+ return undefined;
+ }
+
+ return value;
+ }),
+ );
+
+ const initialConnection: ConnectionBody = {
+ conn_type: connection.conn_type,
+ connection_id: "",
+ description: connection.description ?? "",
+ extra,
+ host: connection.host ?? "",
+ login: connection.login ?? "",
+ password: connection.password === REDACTED ? "" : (connection.password ?? ""),
+ port: connection.port?.toString() ?? "",
+ schema: connection.schema ?? "",
+ team_name: connection.team_name ?? "",
+ };
+
+ return (
+ <>
+
+
+
+
+ {hasRedactedValues ? (
+ {translate("connections.form.helperTextForDuplicatedRedactedFields")}
+ ) : undefined}
+
+
+ >
+ );
+};
+
+export default DuplicateConnectionButton;