This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 c8ad7a98515 UI: Show clear permission toast for 403 errors on user
actions (#61588)
c8ad7a98515 is described below
commit c8ad7a9851596c499c57839fdf306ef80bc7d1c6
Author: Abhishek Mishra <[email protected]>
AuthorDate: Tue Mar 24 22:34:58 2026 +0530
UI: Show clear permission toast for 403 errors on user actions (#61588)
---
.../airflow/ui/public/i18n/locales/en/common.json | 10 ++-
.../ui/src/components/ui/Toaster/createToaster.ts | 8 +-
.../ui/src/components/ui/createErrorToaster.ts | 40 ----------
.../src/airflow/ui/src/queries/useClearRun.ts | 17 ++--
.../ui/src/queries/useClearTaskInstances.ts | 9 +++
.../src/airflow/ui/src/queries/useDagParsing.ts | 12 +--
.../airflow/ui/src/queries/useDeleteConnection.ts | 18 +++--
.../src/airflow/ui/src/queries/useDeleteDag.ts | 18 +++--
.../src/airflow/ui/src/queries/useDeleteDagRun.ts | 16 ++--
.../src/airflow/ui/src/queries/useDeletePool.ts | 18 +++--
.../ui/src/queries/useDeleteTaskInstance.ts | 16 ++--
.../airflow/ui/src/queries/useDeleteVariable.ts | 18 +++--
.../src/airflow/ui/src/queries/usePatchDagRun.ts | 19 ++---
.../airflow/ui/src/queries/usePatchTaskInstance.ts | 19 ++---
.../src/airflow/ui/src/queries/useTogglePause.ts | 10 +--
.../src/airflow/ui/src/queries/useTrigger.ts | 11 +--
.../airflow/ui/src/queries/useUpdateHITLDetail.ts | 9 +--
airflow-core/src/airflow/ui/src/queryClient.ts | 30 +++++++-
.../src/airflow/ui/src/utils/errorHandling.ts | 90 ++++++++++++++++++++++
airflow-core/src/airflow/ui/src/utils/index.ts | 1 +
20 files changed, 248 insertions(+), 141 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 4176a915062..0bbea8fc42d 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -103,6 +103,12 @@
"notFound": "Page Not Found",
"title": "Error"
},
+ "errors": {
+ "forbidden": {
+ "description": "You do not have permission to perform this action.",
+ "title": "Access Denied"
+ }
+ },
"expand": {
"collapse": "Collapse",
"expand": "Expand",
@@ -314,10 +320,6 @@
"title": "Delete {{resourceName}} Request Submitted"
}
},
- "forbidden": {
- "description": "You do not have permission to perform this action.",
- "title": "Access Denied"
- },
"import": {
"error": "Import {{resourceName}} Request Failed",
"success": {
diff --git
a/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts
b/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts
index 9fa1c393c77..b7c78202bdf 100644
--- a/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts
+++ b/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts
@@ -18,7 +18,13 @@
*/
import { createToaster } from "@chakra-ui/react";
-export const toaster = createToaster({
+const baseToaster = createToaster({
pauseOnPageIdle: true,
placement: "bottom-end",
});
+
+// Extend toaster with isActive alias for consistency
+export const toaster = {
+ ...baseToaster,
+ isActive: (id: string) => baseToaster.isVisible(id),
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/ui/createErrorToaster.ts
b/airflow-core/src/airflow/ui/src/components/ui/createErrorToaster.ts
deleted file mode 100644
index c59cabbf3b6..00000000000
--- a/airflow-core/src/airflow/ui/src/components/ui/createErrorToaster.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/*!
- * 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 { TFunction } from "i18next";
-
-import type { ExpandedApiError } from "src/components/ErrorAlert";
-import { toaster } from "src/components/ui";
-
-type ErrorToastMessage = {
- readonly description: string;
- readonly title: string;
-};
-
-export const createErrorToaster =
- (translate: TFunction, fallbackMessage: ErrorToastMessage) => (error:
unknown) => {
- const isForbidden = (error as ExpandedApiError).status === 403;
-
- toaster.create({
- description: isForbidden
- ? translate("toaster.forbidden.description", { ns: "common" })
- : fallbackMessage.description,
- title: isForbidden ? translate("toaster.forbidden.title", { ns: "common"
}) : fallbackMessage.title,
- type: "error",
- });
- };
diff --git a/airflow-core/src/airflow/ui/src/queries/useClearRun.ts
b/airflow-core/src/airflow/ui/src/queries/useClearRun.ts
index af234cc39fc..1749955525e 100644
--- a/airflow-core/src/airflow/ui/src/queries/useClearRun.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useClearRun.ts
@@ -28,7 +28,7 @@ import {
useTaskInstanceServiceGetTaskInstancesKey,
UseGridServiceGetGridRunsKeyFn,
} from "openapi/queries";
-import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
import { useClearDagRunDryRunKey } from "./useClearDagRunDryRun";
@@ -44,12 +44,15 @@ export const useClearDagRun = ({
const queryClient = useQueryClient();
const { t: translate } = useTranslation("dags");
- const onError = (error: Error) => {
- toaster.create({
- description: error.message,
- title: translate("dags:runAndTaskActions.clear.error", { type:
translate("dagRun_one") }),
- type: "error",
- });
+ const onError = (error: unknown) => {
+ createErrorToaster(
+ error,
+ {
+ params: { type: translate("dagRun_one") },
+ titleKey: "dags:runAndTaskActions.clear.error",
+ },
+ translate,
+ );
};
const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts
b/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts
index e8d13ad000e..b3dc26c9de7 100644
--- a/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts
@@ -50,6 +50,15 @@ export const useClearTaskInstances = ({
let detail: string;
let description: string;
+ // Get status from error
+ const status =
+ (error as { status?: number }).status ?? (error as { response?: {
status?: number } }).response?.status;
+
+ // Skip 403 errors as they are handled by MutationCache
+ if (status === 403) {
+ return;
+ }
+
// Narrow the type safely
if (typeof error === "object" && error !== null) {
const apiError = error as ApiError;
diff --git a/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts
b/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts
index 2fc8c43c0e3..8557c271587 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts
@@ -25,15 +25,15 @@ import {
UseDagSourceServiceGetDagSourceKeyFn,
} from "openapi/queries";
import { toaster } from "src/components/ui";
-import { createErrorToaster } from "src/components/ui/createErrorToaster";
+import { createErrorToaster } from "src/utils";
export const useDagParsing = ({ dagId }: { readonly dagId: string }) => {
const queryClient = useQueryClient();
- const { t: translate } = useTranslation(["dag", "common"]);
- const onError = createErrorToaster(translate, {
- description: translate("parse.toaster.error.description"),
- title: translate("parse.toaster.error.title"),
- });
+ const { t: translate } = useTranslation("dag");
+
+ const onError = (error: unknown) => {
+ createErrorToaster(error, { titleKey: "dag:parse.toaster.error.title" },
translate);
+ };
const onSuccess = async () => {
await queryClient.invalidateQueries({
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts
b/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts
index 51e27589fc0..2b54384f8a3 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts
@@ -21,19 +21,21 @@ import { useTranslation } from "react-i18next";
import { useConnectionServiceDeleteConnection,
useConnectionServiceGetConnectionsKey } from "openapi/queries";
import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
export const useDeleteConnection = ({ onSuccessConfirm }: { onSuccessConfirm:
() => void }) => {
const queryClient = useQueryClient();
const { t: translate } = useTranslation(["admin", "common"]);
- const onError = (error: Error) => {
- toaster.create({
- description: error.message,
- title: translate("common:toaster.delete.error", {
- resourceName: translate("admin:connections.connection_one"),
- }),
- type: "error",
- });
+ const onError = (error: unknown) => {
+ createErrorToaster(
+ error,
+ {
+ params: { resourceName: translate("admin:connections.connection_one")
},
+ titleKey: "common:toaster.delete.error",
+ },
+ translate,
+ );
};
const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts
b/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts
index bdd61379a2a..28518dd01ef 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts
@@ -22,6 +22,7 @@ import { useTranslation } from "react-i18next";
import { useDagServiceDeleteDag, useDagServiceGetDagsUiKey } from
"openapi/queries";
import { useDagServiceGetDagKey } from "openapi/queries";
import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
export const useDeleteDag = ({
dagId,
@@ -33,14 +34,15 @@ export const useDeleteDag = ({
const queryClient = useQueryClient();
const { t: translate } = useTranslation();
- const onError = (error: Error) => {
- toaster.create({
- description: error.message,
- title: translate("toaster.delete.error", {
- resourceName: translate("dag_one"),
- }),
- type: "error",
- });
+ const onError = (error: unknown) => {
+ createErrorToaster(
+ error,
+ {
+ params: { resourceName: translate("dag_one") },
+ titleKey: "toaster.delete.error",
+ },
+ translate,
+ );
};
const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
b/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
index c5c75320551..91de2ba4f75 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
@@ -27,6 +27,7 @@ import {
useTaskInstanceServiceGetHitlDetailsKey,
} from "openapi/queries";
import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
type DeleteDagRunParams = {
dagId: string;
@@ -38,12 +39,15 @@ export const useDeleteDagRun = ({ dagId, dagRunId,
onSuccessConfirm }: DeleteDag
const { t: translate } = useTranslation();
const queryClient = useQueryClient();
- const onError = (error: Error) => {
- toaster.create({
- description: error.message,
- title: translate("dags:runAndTaskActions.delete.error", { type:
translate("dagRun_one") }),
- type: "error",
- });
+ const onError = (error: unknown) => {
+ createErrorToaster(
+ error,
+ {
+ params: { type: translate("dagRun_one") },
+ titleKey: "dags:runAndTaskActions.delete.error",
+ },
+ translate,
+ );
};
const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts
b/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts
index 87af3f9f7e2..7ce3857846a 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts
@@ -21,19 +21,21 @@ import { useTranslation } from "react-i18next";
import { usePoolServiceDeletePool, usePoolServiceGetPoolsKey } from
"openapi/queries";
import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
export const useDeletePool = ({ onSuccessConfirm }: { onSuccessConfirm: () =>
void }) => {
const queryClient = useQueryClient();
const { t: translate } = useTranslation(["common", "admin"]);
- const onError = (error: Error) => {
- toaster.create({
- description: error.message,
- title: translate("toaster.delete.error", {
- resourceName: translate("admin:pools.pool_one"),
- }),
- type: "error",
- });
+ const onError = (error: unknown) => {
+ createErrorToaster(
+ error,
+ {
+ params: { resourceName: translate("admin:pools.pool_one") },
+ titleKey: "toaster.delete.error",
+ },
+ translate,
+ );
};
const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
b/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
index 0d7e8f266c3..cdf07e49056 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
@@ -28,6 +28,7 @@ import {
useTaskInstanceServiceGetHitlDetailsKey,
} from "openapi/queries";
import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
type DeleteTaskInstanceParams = {
dagId: string;
@@ -47,12 +48,15 @@ export const useDeleteTaskInstance = ({
const queryClient = useQueryClient();
const { t: translate } = useTranslation(["common", "dags"]);
- const onError = (error: Error) => {
- toaster.create({
- description: error.message,
- title: translate("dags:runAndTaskActions.delete.error", { type:
translate("taskInstance_one") }),
- type: "error",
- });
+ const onError = (error: unknown) => {
+ createErrorToaster(
+ error,
+ {
+ params: { type: translate("taskInstance_one") },
+ titleKey: "dags:runAndTaskActions.delete.error",
+ },
+ translate,
+ );
};
const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts
b/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts
index a82544cb20f..9aee06d7f05 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts
@@ -21,19 +21,21 @@ import { useTranslation } from "react-i18next";
import { useVariableServiceDeleteVariable, useVariableServiceGetVariablesKey }
from "openapi/queries";
import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
export const useDeleteVariable = ({ onSuccessConfirm }: { onSuccessConfirm: ()
=> void }) => {
const queryClient = useQueryClient();
const { t: translate } = useTranslation(["common", "admin"]);
- const onError = (error: Error) => {
- toaster.create({
- description: error.message,
- title: translate("toaster.delete.error", {
- resourceName: translate("admin:variables.variable_one"),
- }),
- type: "error",
- });
+ const onError = (error: unknown) => {
+ createErrorToaster(
+ error,
+ {
+ params: { resourceName: translate("admin:variables.variable_one") },
+ titleKey: "toaster.delete.error",
+ },
+ translate,
+ );
};
const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts
b/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts
index a49fcba8592..0c46147a7b4 100644
--- a/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts
+++ b/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts
@@ -26,7 +26,7 @@ import {
useTaskInstanceServiceGetTaskInstancesKey,
UseGridServiceGetGridRunsKeyFn,
} from "openapi/queries";
-import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
import { useClearDagRunDryRunKey } from "./useClearDagRunDryRun";
@@ -42,14 +42,15 @@ export const usePatchDagRun = ({
const queryClient = useQueryClient();
const { t: translate } = useTranslation();
- const onError = (error: Error) => {
- toaster.create({
- description: error.message,
- title: translate("toaster.update.error", {
- resourceName: translate("dagRun_one"),
- }),
- type: "error",
- });
+ const onError = (error: unknown) => {
+ createErrorToaster(
+ error,
+ {
+ params: { resourceName: translate("dagRun_one") },
+ titleKey: "toaster.update.error",
+ },
+ translate,
+ );
};
const onSuccessFn = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts
b/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts
index b94fadf8e13..402e8d63540 100644
--- a/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts
+++ b/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts
@@ -26,7 +26,7 @@ import {
useTaskInstanceServicePatchTaskInstance,
UseGridServiceGetGridRunsKeyFn,
} from "openapi/queries";
-import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
import { useClearTaskInstancesDryRunKey } from "./useClearTaskInstancesDryRun";
import { usePatchTaskInstanceDryRunKey } from "./usePatchTaskInstanceDryRun";
@@ -47,14 +47,15 @@ export const usePatchTaskInstance = ({
const queryClient = useQueryClient();
const { t: translate } = useTranslation();
- const onError = (error: Error) => {
- toaster.create({
- description: error.message,
- title: translate("toaster.update.error", {
- resourceName: translate("taskInstance_one"),
- }),
- type: "error",
- });
+ const onError = (error: unknown) => {
+ createErrorToaster(
+ error,
+ {
+ params: { resourceName: translate("taskInstance_one") },
+ titleKey: "toaster.update.error",
+ },
+ translate,
+ );
};
const onSuccessFn = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts
b/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts
index c19b5124586..04a7c510acb 100644
--- a/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts
@@ -27,7 +27,7 @@ import {
useDagServiceGetDagsUiKey,
UseTaskInstanceServiceGetTaskInstancesKeyFn,
} from "openapi/queries";
-import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
export const useTogglePause = ({ dagId }: { dagId: string }) => {
const queryClient = useQueryClient();
@@ -45,12 +45,8 @@ export const useTogglePause = ({ dagId }: { dagId: string })
=> {
await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({
queryKey: key })));
};
- const onError = (error: Error) => {
- toaster.create({
- description: error.message,
- title: translate("error.title"),
- type: "error",
- });
+ const onError = (error: unknown) => {
+ createErrorToaster(error, { titleKey: "common:error.title" }, translate);
};
return useDagServicePatchDag({
diff --git a/airflow-core/src/airflow/ui/src/queries/useTrigger.ts
b/airflow-core/src/airflow/ui/src/queries/useTrigger.ts
index 8d1b24afb52..16252358afa 100644
--- a/airflow-core/src/airflow/ui/src/queries/useTrigger.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useTrigger.ts
@@ -31,6 +31,7 @@ import {
import type { TriggerDagRunResponse } from "openapi/requests/types.gen";
import type { DagRunTriggerParams } from "src/components/TriggerDag/types";
import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
export const useTrigger = ({ dagId, onSuccessConfirm }: { dagId: string;
onSuccessConfirm: () => void }) => {
const queryClient = useQueryClient();
@@ -62,13 +63,9 @@ export const useTrigger = ({ dagId, onSuccessConfirm }: {
dagId: string; onSucce
}
};
- const onError = (_error: Error) => {
- toaster.create({
- description: _error.message,
- title: translate("triggerDag.toaster.error.title"),
- type: "error",
- });
- setError(_error);
+ const onError = (apiError: unknown) => {
+ createErrorToaster(apiError, { titleKey:
"components:triggerDag.toaster.error.title" }, translate);
+ setError(apiError);
};
const { isPending, mutate } = useDagRunServiceTriggerDagRun({
diff --git a/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
index 390651b7849..bb00c8466f3 100644
--- a/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
@@ -30,6 +30,7 @@ import {
useTaskInstanceServiceGetTaskInstancesKey,
} from "openapi/queries";
import { toaster } from "src/components/ui/Toaster";
+import { createErrorToaster } from "src/utils";
import type { HITLResponseParams } from "src/utils/hitl";
export const useUpdateHITLDetail = ({
@@ -64,12 +65,8 @@ export const useUpdateHITLDetail = ({
});
};
- const onError = (_error: Error) => {
- toaster.create({
- description: _error.message,
- title: translate("response.error"),
- type: "error",
- });
+ const onError = (apiError: unknown) => {
+ createErrorToaster(apiError, { titleKey: "hitl:response.error" },
translate);
};
const { isPending, mutate } = useTaskInstanceServiceUpdateHitlDetail({
diff --git a/airflow-core/src/airflow/ui/src/queryClient.ts
b/airflow-core/src/airflow/ui/src/queryClient.ts
index 7ebc52c55d5..e465402c308 100644
--- a/airflow-core/src/airflow/ui/src/queryClient.ts
+++ b/airflow-core/src/airflow/ui/src/queryClient.ts
@@ -16,9 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { QueryClient } from "@tanstack/react-query";
+import { MutationCache, QueryClient } from "@tanstack/react-query";
import { OpenAPI } from "openapi/requests/core/OpenAPI";
+import { toaster } from "src/components/ui";
+import i18n from "src/i18n/config";
+import { getErrorStatus } from "src/utils";
// Dynamically set the base URL for XHR requests based on the meta tag.
OpenAPI.BASE = document.querySelector("head>base")?.getAttribute("href") ?? "";
@@ -39,6 +42,28 @@ const retryFunction = (failureCount: number, error: unknown)
=> {
return failureCount < RETRY_COUNT;
};
+// Track active 403 toast to prevent duplicates when multiple mutations fail
+let active403ToastId: string | undefined;
+
+// Error handler for 403 (Forbidden) responses on user-initiated actions
+const handle403Error = (error: unknown) => {
+ // Check for 403 (Forbidden) only to avoid interfering with 401 (Auth) logic
+ const status = getErrorStatus(error);
+
+ if (status === 403) {
+ // Only show one 403 toast at a time to prevent toast spam
+ // when multiple mutations fail simultaneously
+ if (active403ToastId === undefined || !toaster.isActive(active403ToastId))
{
+ active403ToastId = toaster.create({
+ description: i18n.t("errors.forbidden.description"),
+ title: i18n.t("errors.forbidden.title"),
+ type: "error",
+ });
+ }
+ }
+ // For other errors, let them bubble up to individual mutation handlers
+};
+
export const client = new QueryClient({
defaultOptions: {
mutations: {
@@ -52,4 +77,7 @@ export const client = new QueryClient({
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
+ mutationCache: new MutationCache({
+ onError: handle403Error,
+ }),
});
diff --git a/airflow-core/src/airflow/ui/src/utils/errorHandling.ts
b/airflow-core/src/airflow/ui/src/utils/errorHandling.ts
new file mode 100644
index 00000000000..f19e5d4dc98
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/errorHandling.ts
@@ -0,0 +1,90 @@
+/*!
+ * 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 { TFunction } from "i18next";
+
+import { toaster } from "src/components/ui";
+
+/**
+ * Type guard to check if an error has a status property
+ */
+type ErrorWithStatus = {
+ message?: string;
+ response?: {
+ status?: number;
+ };
+ status?: number;
+};
+
+/**
+ * Safely extracts the HTTP status code from an error object
+ */
+export const getErrorStatus = (error: unknown): number | undefined => {
+ if (typeof error !== "object" || error === null) {
+ return undefined;
+ }
+
+ const errorObj = error as ErrorWithStatus;
+
+ return errorObj.status ?? errorObj.response?.status;
+};
+
+/**
+ * Safely extracts the error message from an error object
+ */
+const getErrorMessage = (error: unknown): string => {
+ if (typeof error !== "object" || error === null) {
+ return String(error);
+ }
+
+ const errorObj = error as ErrorWithStatus;
+
+ return errorObj.message ?? "An error occurred";
+};
+
+/**
+ * Creates an error toaster notification with standardized behavior.
+ * Skips 403 errors as they are handled by MutationCache.
+ *
+ * @param error - The error object to process
+ * @param options - Configuration options
+ * @param options.params - Optional parameters for translation interpolation
+ * @param options.titleKey - The translation key for the error title
+ * @param translate - The i18next translate function
+ */
+export const createErrorToaster = (
+ error: unknown,
+ options: { params?: Record<string, string>; titleKey: string },
+ translate: TFunction,
+): void => {
+ const status = getErrorStatus(error);
+
+ // Skip 403 errors as they are handled by MutationCache
+ if (status === 403) {
+ return;
+ }
+
+ const message = getErrorMessage(error);
+ const title = translate(options.titleKey, options.params);
+
+ toaster.create({
+ description: message,
+ title,
+ type: "error",
+ });
+};
diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts
b/airflow-core/src/airflow/ui/src/utils/index.ts
index e9f6904d96e..486cc4bee66 100644
--- a/airflow-core/src/airflow/ui/src/utils/index.ts
+++ b/airflow-core/src/airflow/ui/src/utils/index.ts
@@ -19,6 +19,7 @@
export { capitalize } from "./capitalize";
export { getDuration, renderDuration } from "./datetimeUtils";
+export { createErrorToaster, getErrorStatus } from "./errorHandling";
export { getMetaKey } from "./getMetaKey";
export { useContainerWidth } from "./useContainerWidth";
export { useFiltersHandler, type FilterableSearchParamsKeys } from
"./useFiltersHandler";