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,
});