mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-11-05 01:10:20 +00:00
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:
1 parent
d4fe4e5806
commit
2750d9858a
8 files changed
+89
-12
No files matched your search
@@ -9,6 +9,14 @@ import type {
|
|||||||
SettingResponse,
|
SettingResponse,
|
||||||
} from "@/types/nezha-api";
|
} 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;
|
let lastestRefreshTokenAt = 0;
|
||||||
|
|
||||||
const getCookieValue = (name: string): string | undefined => {
|
const getCookieValue = (name: string): string | undefined => {
|
||||||
|
|||||||
@@ -115,7 +115,9 @@
|
|||||||
"pageNotFound": "Page not found",
|
"pageNotFound": "Page not found",
|
||||||
"backToHome": "Back to home",
|
"backToHome": "Back to home",
|
||||||
"backendUnavailableTitle": "Backend API unavailable",
|
"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": {
|
"tabSwitch": {
|
||||||
"Detail": "Detail",
|
"Detail": "Detail",
|
||||||
|
|||||||
@@ -108,7 +108,9 @@
|
|||||||
"pageNotFound": "Halaman tidak ditemukan",
|
"pageNotFound": "Halaman tidak ditemukan",
|
||||||
"backToHome": "Kembali ke beranda",
|
"backToHome": "Kembali ke beranda",
|
||||||
"backendUnavailableTitle": "API backend tidak tersedia",
|
"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": {
|
"tabSwitch": {
|
||||||
"Detail": "Detail",
|
"Detail": "Detail",
|
||||||
|
|||||||
@@ -108,7 +108,9 @@
|
|||||||
"pageNotFound": "Página não encontrada",
|
"pageNotFound": "Página não encontrada",
|
||||||
"backToHome": "Voltar ao inicio",
|
"backToHome": "Voltar ao inicio",
|
||||||
"backendUnavailableTitle": "API Backend indisponível",
|
"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": {
|
"tabSwitch": {
|
||||||
"Detail": "Detalhes",
|
"Detail": "Detalhes",
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"pageNotFound": "页面不存在",
|
"pageNotFound": "页面不存在",
|
||||||
"backToHome": "回到主页",
|
"backToHome": "回到主页",
|
||||||
"backendUnavailableTitle": "后端 API 无法访问",
|
"backendUnavailableTitle": "后端 API 无法访问",
|
||||||
"backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。"
|
"backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。",
|
||||||
|
"loginRequiredTitle": "需要登录",
|
||||||
|
"loginRequiredDescription": "当前站点已开启强制登录,登录后即可加载监控数据。"
|
||||||
},
|
},
|
||||||
"tabSwitch": {
|
"tabSwitch": {
|
||||||
"Detail": "详情",
|
"Detail": "详情",
|
||||||
|
|||||||
+27
-7
@@ -17,11 +17,16 @@ import ServerCard from "@/components/ServerCard";
|
|||||||
import ServerCardInline from "@/components/ServerCardInline";
|
import ServerCardInline from "@/components/ServerCardInline";
|
||||||
import ServerOverview from "@/components/ServerOverview";
|
import ServerOverview from "@/components/ServerOverview";
|
||||||
import { ServiceTracker } from "@/components/ServiceTracker";
|
import { ServiceTracker } from "@/components/ServiceTracker";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { SORT_TYPES } from "@/context/sort-context";
|
import { SORT_TYPES } from "@/context/sort-context";
|
||||||
import { useSort } from "@/hooks/use-sort";
|
import { useSort } from "@/hooks/use-sort";
|
||||||
import { useStatus } from "@/hooks/use-status";
|
import { useStatus } from "@/hooks/use-status";
|
||||||
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
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 { cn } from "@/lib/utils";
|
||||||
import type { NezhaServer, ServerGroup } from "@/types/nezha-api";
|
import type { NezhaServer, ServerGroup } from "@/types/nezha-api";
|
||||||
|
|
||||||
@@ -53,21 +58,36 @@ const getErrorMessage = (error: unknown) => {
|
|||||||
|
|
||||||
function BackendErrorState({ error }: { error: unknown }) {
|
function BackendErrorState({ error }: { error: unknown }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const unauthorized = isUnauthorizedError(error);
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-96 flex-col items-center justify-center px-4 text-center">
|
<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">
|
<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">
|
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">
|
||||||
{t("error.backendUnavailableTitle")}
|
{t(
|
||||||
|
unauthorized
|
||||||
|
? "error.loginRequiredTitle"
|
||||||
|
: "error.backendUnavailableTitle",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("error.backendUnavailableDescription")}
|
{t(
|
||||||
|
unauthorized
|
||||||
|
? "error.loginRequiredDescription"
|
||||||
|
: "error.backendUnavailableDescription",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{message && (
|
{unauthorized ? (
|
||||||
<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">
|
<Button asChild className="mt-2">
|
||||||
{message}
|
<a href="/dashboard">{t("login")}</a>
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
fetchServerMetrics,
|
fetchServerMetrics,
|
||||||
fetchService,
|
fetchService,
|
||||||
fetchSetting,
|
fetchSetting,
|
||||||
|
isUnauthorizedError,
|
||||||
} from "@/lib/nezha-api";
|
} from "@/lib/nezha-api";
|
||||||
|
|
||||||
const jsonResponse = (body: unknown, init?: ResponseInit) =>
|
const jsonResponse = (body: unknown, init?: ResponseInit) =>
|
||||||
@@ -52,6 +53,12 @@ describe("nezha api fetchers", () => {
|
|||||||
await expect(fetchService()).rejects.toThrow("service unavailable");
|
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 () => {
|
it("adds monitor and metrics query parameters when periods are provided", async () => {
|
||||||
vi.mocked(fetch)
|
vi.mocked(fetch)
|
||||||
.mockResolvedValueOnce(jsonResponse({ success: true, data: [] }))
|
.mockResolvedValueOnce(jsonResponse({ success: true, data: [] }))
|
||||||
|
|||||||
@@ -16,7 +16,15 @@ const apiMocks = vi.hoisted(() => ({
|
|||||||
fetchService: vi.fn(),
|
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", () => ({
|
vi.mock("@/components/GlobalMap", () => ({
|
||||||
default: ({ serverList }: { serverList: NezhaServer[] }) => (
|
default: ({ serverList }: { serverList: NezhaServer[] }) => (
|
||||||
@@ -217,6 +225,32 @@ describe("Servers page", () => {
|
|||||||
expect(screen.getByText("group failed")).toBeInTheDocument();
|
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 () => {
|
it("summarizes online and offline servers from websocket data", async () => {
|
||||||
const online = createServer({ id: 1, name: "alpha" });
|
const online = createServer({ id: 1, name: "alpha" });
|
||||||
const offline = createServer({
|
const offline = createServer({
|
||||||
|
|||||||
Reference in new issue
Block a user