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

guoqqqi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new e0fc19d03 fix: reject 401 responses instead of resolving fabricated 
empty data (#3418)
e0fc19d03 is described below

commit e0fc19d03564a317107d89da6c80a27d92239942
Author: Ming Wen <[email protected]>
AuthorDate: Mon Jul 13 10:03:45 2026 +0800

    fix: reject 401 responses instead of resolving fabricated empty data (#3418)
---
 .../regression/auth.401-no-false-success.spec.ts   | 121 +++++++++++++++++++++
 src/components/page/DeleteResourceBtn.tsx          |   9 +-
 src/config/global.ts                               |  19 +++-
 src/config/req.ts                                  |   7 +-
 src/locales/de/common.json                         |   4 +
 src/locales/en/common.json                         |   5 +-
 src/locales/es/common.json                         |   4 +
 src/locales/tr/common.json                         |   4 +
 src/locales/zh/common.json                         |   4 +
 src/routes/__root.tsx                              |  49 ++++++++-
 10 files changed, 218 insertions(+), 8 deletions(-)

diff --git a/e2e/tests/regression/auth.401-no-false-success.spec.ts 
b/e2e/tests/regression/auth.401-no-false-success.spec.ts
new file mode 100644
index 000000000..a5e3d9d36
--- /dev/null
+++ b/e2e/tests/regression/auth.401-no-false-success.spec.ts
@@ -0,0 +1,121 @@
+/**
+ * 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.
+ */
+
+// Regression for the 401 interceptor behavior (apache/apisix-dashboard#3415):
+// the axios interceptor used to convert a 401 into a *resolved* response with
+// fabricated `{ data: {} }`. That made a 401'd DELETE show the green
+// "Delete ... Successfully" toast (and fed `{}` into onSuccess handlers,
+// crashing add/detail pages). The interceptor must open the Settings modal
+// AND reject, so callers take their normal error path.
+
+import { routesPom } from '@e2e/pom/routes';
+import { getAPISIXConf, randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, putRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.beforeAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test('a 401 response must not produce a success toast', async ({ page }) => {
+  const name = randomId('reg-401');
+  await putRouteReq(e2eReq, {
+    name,
+    uri: `/regression/401/${name}`,
+    methods: ['GET'],
+    upstream: {
+      type: 'roundrobin',
+      nodes: { 'reg-401.local:80': 1 },
+    },
+  } as APISIXType['Route']);
+
+  await routesPom.toIndex(page);
+  await routesPom.isIndexPage(page);
+
+  const row = page.getByRole('row', { name });
+  await expect(row).toBeVisible();
+
+  // Force the DELETE to come back as a 401, the way an expired/rotated
+  // admin key would.
+  await page.route('**/apisix/admin/routes/**', async (route) => {
+    if (route.request().method() === 'DELETE') {
+      await route.fulfill({
+        status: 401,
+        contentType: 'application/json',
+        body: JSON.stringify({ message: 'failed to check token' }),
+      });
+    } else {
+      await route.fallback();
+    }
+  });
+
+  await row.getByRole('button', { name: 'Delete' }).click();
+  await page
+    .getByRole('dialog')
+    .getByRole('button', { name: 'Delete' })
+    .click();
+
+  // The settings modal must open so the user can fix the key ...
+  await expect(page.getByRole('dialog', { name: 'Settings' })).toBeVisible();
+
+  // Anchor on the interceptor's red toast first: it is emitted in both the
+  // old (fake-success) and new (reject) worlds strictly before the success
+  // chain could toast, so the negative assertion below is order-safe.
+  await expect(
+    page.getByRole('alert').filter({ hasText: 'failed to check token' })
+  ).toBeVisible();
+
+  // ... and no success toast may appear; the row must survive.
+  const successToast = page
+    .getByRole('alert')
+    .filter({ hasText: /successfully/i });
+  await expect(successToast).toHaveCount(0);
+  await expect(row).toBeVisible();
+});
+
+test.describe('fresh install recovery', () => {
+  // no stored admin key: every request 401s until the user enters one
+  test.use({ storageState: { cookies: [], origins: [] } });
+
+  test('entering the key and clicking Retry recovers without a reload', async 
({
+    page,
+  }) => {
+    await routesPom.toIndex(page);
+
+    // the root error component replaces the page but keeps the modal
+    await expect(page.getByText('Something went wrong')).toBeVisible();
+    const settingsModal = page.getByRole('dialog', { name: 'Settings' });
+    await expect(settingsModal).toBeVisible();
+
+    const { adminKey } = await getAPISIXConf();
+    await page.getByLabel('Admin Key').fill(adminKey);
+    // close the modal, then recover via Retry — no page.reload()
+    await settingsModal.getByRole('button').first().click();
+    await page.getByRole('button', { name: 'Retry' }).click();
+
+    await expect(page.getByText('Something went wrong')).toBeHidden();
+    await routesPom.isIndexPage(page);
+  });
+});
diff --git a/src/components/page/DeleteResourceBtn.tsx 
b/src/components/page/DeleteResourceBtn.tsx
index d6e6ef6ee..fcaddde03 100644
--- a/src/components/page/DeleteResourceBtn.tsx
+++ b/src/components/page/DeleteResourceBtn.tsx
@@ -18,7 +18,7 @@ import { Button, type ButtonProps, Text } from 
'@mantine/core';
 import { useCallbackRef } from '@mantine/hooks';
 import { modals } from '@mantine/modals';
 import { notifications } from '@mantine/notifications';
-import type { AxiosResponse } from 'axios';
+import { type AxiosResponse, isAxiosError } from 'axios';
 import { useTranslation } from 'react-i18next';
 
 import { queryClient } from '@/config/global';
@@ -85,6 +85,13 @@ export const DeleteResourceBtn = (props: 
DeleteResourceProps) => {
             // So this is a workaround for now
             // TODO: remove this
             queryClient.invalidateQueries();
+          })
+          .catch((e) => {
+            // the axios interceptor owns the error toast for HTTP/network
+            // failures; swallow only those so a failed delete does not
+            // surface as an unhandled rejection, while real bugs thrown
+            // by onSuccess/invalidate still propagate
+            if (!isAxiosError(e)) throw e;
           }),
     })
   );
diff --git a/src/config/global.ts b/src/config/global.ts
index e39744e50..c1dfad63b 100644
--- a/src/config/global.ts
+++ b/src/config/global.ts
@@ -16,6 +16,7 @@
  */
 import { QueryClient } from '@tanstack/react-query';
 import { createRouter } from '@tanstack/react-router';
+import { HttpStatusCode, isAxiosError } from 'axios';
 
 import { routeTree } from '@/routeTree.gen';
 
@@ -25,4 +26,20 @@ export const router = createRouter({ routeTree, basepath: 
BASE_PATH });
 
 export type Router = typeof router;
 
-export const queryClient = new QueryClient({});
+export const queryClient = new QueryClient({
+  defaultOptions: {
+    queries: {
+      retry: (failureCount, error) => {
+        // retrying a 401 cannot succeed until the user fixes the admin
+        // key; fail fast so the settings modal appears immediately
+        if (
+          isAxiosError(error) &&
+          error.response?.status === HttpStatusCode.Unauthorized
+        ) {
+          return false;
+        }
+        return failureCount < 3;
+      },
+    },
+  },
+});
diff --git a/src/config/req.ts b/src/config/req.ts
index 078bd513f..7c94258f2 100644
--- a/src/config/req.ts
+++ b/src/config/req.ts
@@ -85,10 +85,13 @@ req.interceptors.response.use(
         message,
         color: 'red',
       });
-      // Requires to enter admin key at 401
+      // Requires to enter admin key at 401.
+      // Note: do NOT resolve with fabricated data here — callers must take
+      // their normal error path. Resolving `{ data: {} }` made a 401'd
+      // DELETE/PUT show success toasts and fed `{}` into `onSuccess`
+      // handlers and suspense queries, crashing detail/add pages.
       if (res.status === HttpStatusCode.Unauthorized) {
         getDefaultStore().set(isSettingsOpenAtom, true);
-        return Promise.resolve({ data: {} });
       }
     }
     return Promise.reject(err);
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index 95cfb9ad5..0fbf9dfe1 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -370,5 +370,9 @@
   },
   "upstreams": {
     "singular": "Upstream"
+  },
+  "error": {
+    "title": "Etwas ist schiefgelaufen",
+    "retry": "Erneut versuchen"
   }
 }
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index 9625981d2..5209795a4 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -336,7 +336,6 @@
   "services": {
     "singular": "Service",
     "empty": "No services found. Click Add Service to create your first one."
-
   },
   "settings": {
     "adminKey": "Admin Key",
@@ -371,5 +370,9 @@
   },
   "upstreams": {
     "singular": "Upstream"
+  },
+  "error": {
+    "title": "Something went wrong",
+    "retry": "Retry"
   }
 }
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index b57953e9c..d6188dfcf 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -370,5 +370,9 @@
   },
   "upstreams": {
     "singular": "Upstream"
+  },
+  "error": {
+    "title": "Algo salió mal",
+    "retry": "Reintentar"
   }
 }
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index 46da33e48..c20dccc76 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -370,5 +370,9 @@
   },
   "upstreams": {
     "singular": "Upstream"
+  },
+  "error": {
+    "title": "Bir şeyler ters gitti",
+    "retry": "Yeniden dene"
   }
 }
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index a8097fa1d..6a82d155a 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -370,5 +370,9 @@
   },
   "upstreams": {
     "singular": "上游"
+  },
+  "error": {
+    "title": "出错了",
+    "retry": "重试"
   }
 }
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 0ce486324..e9bc3031d 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -14,12 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { AppShell } from '@mantine/core';
+import { AppShell, Button, Code, Stack, Text } from '@mantine/core';
 import { useDisclosure } from '@mantine/hooks';
+import { useQueryErrorResetBoundary } from '@tanstack/react-query';
 import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
-import { createRootRoute, HeadContent, Outlet } from '@tanstack/react-router';
+import {
+  createRootRoute,
+  type ErrorComponentProps,
+  HeadContent,
+  Outlet,
+  useRouter,
+} from '@tanstack/react-router';
 import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
-import { I18nextProvider } from 'react-i18next';
+import { useEffect } from 'react';
+import { I18nextProvider, useTranslation } from 'react-i18next';
 
 import { Header } from '@/components/Header';
 import { Navbar } from '@/components/Navbar';
@@ -63,6 +71,41 @@ const Root = () => {
   );
 };
 
+const RootErrorContent = (props: ErrorComponentProps) => {
+  const { error } = props;
+  const { t } = useTranslation();
+  const router = useRouter();
+  // detail pages throw from useSuspenseQuery during render; unless the
+  // query error-reset boundary is reset, react-query re-throws the cached
+  // error on remount and the Retry button would loop back here
+  const queryErrorResetBoundary = useQueryErrorResetBoundary();
+  useEffect(() => {
+    queryErrorResetBoundary.reset();
+  }, [queryErrorResetBoundary]);
+  return (
+    <Stack align="center" justify="center" mih="60vh" gap="md" p="xl">
+      <Text fw={700} size="lg">
+        {t('error.title')}
+      </Text>
+      <Code block>{error.message}</Code>
+      <Button onClick={() => router.invalidate()}>{t('error.retry')}</Button>
+    </Stack>
+  );
+};
+
+/**
+ * Loader/render failures land here when no child route handles them.
+ * The settings modal must stay mounted: on a fresh install every request
+ * 401s until the admin key is entered, and the modal is the only way in.
+ */
+const RootError = (props: ErrorComponentProps) => (
+  <I18nextProvider i18n={i18n}>
+    <RootErrorContent {...props} />
+    <SettingsModal />
+  </I18nextProvider>
+);
+
 export const Route = createRootRoute({
   component: Root,
+  errorComponent: RootError,
 });

Reply via email to