diff --git a/src/lib/nezha-api.ts b/src/lib/nezha-api.ts index 175d1ce..32a1546 100644 --- a/src/lib/nezha-api.ts +++ b/src/lib/nezha-api.ts @@ -9,6 +9,14 @@ import type { SettingResponse, } from "@/types/nezha-api"; +export const API_ERROR_UNAUTHORIZED = "ApiErrorUnauthorized"; + +export function isUnauthorizedError(error: unknown): boolean { + const message = + error instanceof Error ? error.message : error == null ? "" : String(error); + return message === API_ERROR_UNAUTHORIZED; +} + let lastestRefreshTokenAt = 0; const getCookieValue = (name: string): string | undefined => { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 6cdbd61..0b6a474 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -115,7 +115,9 @@ "pageNotFound": "Page not found", "backToHome": "Back to home", "backendUnavailableTitle": "Backend API unavailable", - "backendUnavailableDescription": "The page is still available, but data cannot be loaded right now." + "backendUnavailableDescription": "The page is still available, but data cannot be loaded right now.", + "loginRequiredTitle": "Login required", + "loginRequiredDescription": "This site requires a login to load monitoring data." }, "tabSwitch": { "Detail": "Detail", diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index c7cfda6..7cb3e74 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -108,7 +108,9 @@ "pageNotFound": "Halaman tidak ditemukan", "backToHome": "Kembali ke beranda", "backendUnavailableTitle": "API backend tidak tersedia", - "backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini." + "backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini.", + "loginRequiredTitle": "Perlu masuk", + "loginRequiredDescription": "Situs ini memerlukan login untuk memuat data pemantauan." }, "tabSwitch": { "Detail": "Detail", diff --git a/src/locales/pt_BR/translation.json b/src/locales/pt_BR/translation.json index 9368fd5..745af41 100644 --- a/src/locales/pt_BR/translation.json +++ b/src/locales/pt_BR/translation.json @@ -108,7 +108,9 @@ "pageNotFound": "Página não encontrada", "backToHome": "Voltar ao inicio", "backendUnavailableTitle": "API Backend indisponível", - "backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora." + "backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora.", + "loginRequiredTitle": "Login necessário", + "loginRequiredDescription": "Este site exige login para carregar os dados de monitoramento." }, "tabSwitch": { "Detail": "Detalhes", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 04b967a..bc421ce 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -109,7 +109,9 @@ "pageNotFound": "页面不存在", "backToHome": "回到主页", "backendUnavailableTitle": "后端 API 无法访问", - "backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。" + "backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。", + "loginRequiredTitle": "需要登录", + "loginRequiredDescription": "当前站点已开启强制登录,登录后即可加载监控数据。" }, "tabSwitch": { "Detail": "详情", diff --git a/src/pages/Server.tsx b/src/pages/Server.tsx index 20cc1a9..dcca4bf 100644 --- a/src/pages/Server.tsx +++ b/src/pages/Server.tsx @@ -17,11 +17,16 @@ import ServerCard from "@/components/ServerCard"; import ServerCardInline from "@/components/ServerCardInline"; import ServerOverview from "@/components/ServerOverview"; import { ServiceTracker } from "@/components/ServiceTracker"; +import { Button } from "@/components/ui/button"; import { SORT_TYPES } from "@/context/sort-context"; import { useSort } from "@/hooks/use-sort"; import { useStatus } from "@/hooks/use-status"; import { useWebSocketContext } from "@/hooks/use-websocket-context"; -import { fetchServerGroup, fetchService } from "@/lib/nezha-api"; +import { + fetchServerGroup, + fetchService, + isUnauthorizedError, +} from "@/lib/nezha-api"; import { cn } from "@/lib/utils"; import type { NezhaServer, ServerGroup } from "@/types/nezha-api"; @@ -53,21 +58,36 @@ const getErrorMessage = (error: unknown) => { function BackendErrorState({ error }: { error: unknown }) { const { t } = useTranslation(); + const unauthorized = isUnauthorizedError(error); const message = getErrorMessage(error); return (

- {t("error.backendUnavailableTitle")} + {t( + unauthorized + ? "error.loginRequiredTitle" + : "error.backendUnavailableTitle", + )}

- {t("error.backendUnavailableDescription")} + {t( + unauthorized + ? "error.loginRequiredDescription" + : "error.backendUnavailableDescription", + )}

- {message && ( -

- {message} -

+ {unauthorized ? ( + + ) : ( + message && ( +

+ {message} +

+ ) )}
diff --git a/src/test/lib/nezha-api.test.ts b/src/test/lib/nezha-api.test.ts index 6e17fe3..6e0792d 100644 --- a/src/test/lib/nezha-api.test.ts +++ b/src/test/lib/nezha-api.test.ts @@ -6,6 +6,7 @@ import { fetchServerMetrics, fetchService, fetchSetting, + isUnauthorizedError, } from "@/lib/nezha-api"; const jsonResponse = (body: unknown, init?: ResponseInit) => @@ -52,6 +53,12 @@ describe("nezha api fetchers", () => { await expect(fetchService()).rejects.toThrow("service unavailable"); }); + it("detects guest-auth errors from the dashboard API", () => { + expect(isUnauthorizedError(new Error("ApiErrorUnauthorized"))).toBe(true); + expect(isUnauthorizedError(new Error("settings failed"))).toBe(false); + expect(isUnauthorizedError(null)).toBe(false); + }); + it("adds monitor and metrics query parameters when periods are provided", async () => { vi.mocked(fetch) .mockResolvedValueOnce(jsonResponse({ success: true, data: [] })) diff --git a/src/test/pages/Server.test.tsx b/src/test/pages/Server.test.tsx index 1410718..a54c151 100644 --- a/src/test/pages/Server.test.tsx +++ b/src/test/pages/Server.test.tsx @@ -16,7 +16,15 @@ const apiMocks = vi.hoisted(() => ({ fetchService: vi.fn(), })); -vi.mock("@/lib/nezha-api", () => apiMocks); +vi.mock("@/lib/nezha-api", async () => { + const actual = + await vi.importActual("@/lib/nezha-api"); + return { + ...actual, + fetchServerGroup: apiMocks.fetchServerGroup, + fetchService: apiMocks.fetchService, + }; +}); vi.mock("@/components/GlobalMap", () => ({ default: ({ serverList }: { serverList: NezhaServer[] }) => ( @@ -217,6 +225,32 @@ describe("Servers page", () => { expect(screen.getByText("group failed")).toBeInTheDocument(); }); + it("prompts guests to log in when the API returns ApiErrorUnauthorized", async () => { + apiMocks.fetchServerGroup.mockRejectedValue( + new Error("ApiErrorUnauthorized"), + ); + + renderServerPage({ + connected: false, + lastData: null, + }); + + expect( + await screen.findByText("error.loginRequiredTitle"), + ).toBeInTheDocument(); + expect( + screen.getByText("error.loginRequiredDescription"), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "login" })).toHaveAttribute( + "href", + "/dashboard", + ); + expect( + screen.queryByText("error.backendUnavailableTitle"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("ApiErrorUnauthorized")).not.toBeInTheDocument(); + }); + it("summarizes online and offline servers from websocket data", async () => { const online = createServer({ id: 1, name: "alpha" }); const offline = createServer({