fix: prompt login when guest API returns unauthorized (#98)

When force_auth is on, the guest homepage still loads, but /api/v1/server
returns {"error":"ApiErrorUnauthorized"}. BackendErrorState treated that
the same as a down backend.

Match that error exactly and show a login title, description, and a
button to /dashboard. Other errors keep the old copy and the raw message.

zh-TW is unchanged: that file uses spaces, and biome would reformat the
whole translation.
This commit is contained in:
Galaxy Roaming authored and GitHub committed 2026-10-22 09:09:17 +08:00
1 parent d4fe4e5806
commit 2750d9858a
8 files changed
+89 -12

No files matched your search

+8
View File
@@ -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 => {
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -109,7 +109,9 @@
"pageNotFound": "页面不存在",
"backToHome": "回到主页",
"backendUnavailableTitle": "后端 API 无法访问",
"backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。"
"backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。",
"loginRequiredTitle": "需要登录",
"loginRequiredDescription": "当前站点已开启强制登录,登录后即可加载监控数据。"
},
"tabSwitch": {
"Detail": "详情",
+27 -7
View File
@@ -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 (
<div className="flex min-h-96 flex-col items-center justify-center px-4 text-center">
<div className="flex max-w-md flex-col items-center gap-2">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">
{t("error.backendUnavailableTitle")}
{t(
unauthorized
? "error.loginRequiredTitle"
: "error.backendUnavailableTitle",
)}
</p>
<p className="text-sm text-muted-foreground">
{t("error.backendUnavailableDescription")}
{t(
unauthorized
? "error.loginRequiredDescription"
: "error.backendUnavailableDescription",
)}
</p>
{message && (
<p className="mt-1 max-w-full break-words rounded-md bg-stone-100 px-2 py-1 text-xs text-stone-500 dark:bg-stone-800 dark:text-stone-400">
{message}
</p>
{unauthorized ? (
<Button asChild className="mt-2">
<a href="/dashboard">{t("login")}</a>
</Button>
) : (
message && (
<p className="mt-1 max-w-full break-words rounded-md bg-stone-100 px-2 py-1 text-xs text-stone-500 dark:bg-stone-800 dark:text-stone-400">
{message}
</p>
)
)}
</div>
</div>
+7
View File
@@ -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: [] }))
+35 -1
View File
@@ -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<typeof import("@/lib/nezha-api")>("@/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({