diff --git a/packages/web/src/components/shared/MissingTokenAlert.tsx b/packages/web/src/components/shared/MissingTokenAlert.tsx
new file mode 100644
index 0000000..57d532d
--- /dev/null
+++ b/packages/web/src/components/shared/MissingTokenAlert.tsx
@@ -0,0 +1,57 @@
+import { Link } from "@tanstack/react-router";
+import { AlertTriangle } from "lucide-react";
+import { Body, Caption } from "@/components/ui/typography";
+import { useHealthStatus } from "@/hooks/useHealthStatus";
+import { useInstances } from "@/hooks/useInstances";
+import { COLOR } from "@/lib/constants";
+
+const TITLE = "Authentication token required";
+const BODY = "This Honcho instance requires an authentication token, and none is configured.";
+const ACTION = "Add token in Settings";
+
+export function MissingTokenAlert() {
+ const { active } = useInstances();
+ const { data: health } = useHealthStatus();
+ const tokenMissing = !active?.token?.trim();
+
+ if (!tokenMissing || health?.status !== "auth-required") return null;
+
+ return (
+
+
+
+
+
+ {TITLE}
+
+
+ {BODY}
+
+
+
+
+ {ACTION}
+
+
+ );
+}
diff --git a/packages/web/src/routes/__root.tsx b/packages/web/src/routes/__root.tsx
index 8dce51e..d0f636f 100644
--- a/packages/web/src/routes/__root.tsx
+++ b/packages/web/src/routes/__root.tsx
@@ -1,6 +1,7 @@
import { createRootRoute, Outlet, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
import { Sidebar } from "@/components/layout/Sidebar";
+import { MissingTokenAlert } from "@/components/shared/MissingTokenAlert";
import { loadConfig } from "@/lib/config";
import { applyTheme, getStoredTheme } from "@/lib/theme";
@@ -18,6 +19,7 @@ function RootLayout() {
>
+
diff --git a/packages/web/src/test/check-connection.test.ts b/packages/web/src/test/check-connection.test.ts
index f12f688..6330cb8 100644
--- a/packages/web/src/test/check-connection.test.ts
+++ b/packages/web/src/test/check-connection.test.ts
@@ -36,6 +36,12 @@ describe("checkConnection — web proxy mode", () => {
expect(res.status).toBe("auth-required");
});
+ it("maps an upstream 403 to auth-required", async () => {
+ httpFetchMock.mockResolvedValue(new Response("{}", { status: 403 }));
+ const res = await checkConnection("https://honcho.example.net");
+ expect(res.status).toBe("auth-required");
+ });
+
it("treats a proxy reject as unreachable, not auth-required", async () => {
httpFetchMock.mockResolvedValue(
new Response("", { status: 403, headers: { "X-Honcho-Proxy-Reject": "allowlist" } }),
diff --git a/packages/web/src/test/missing-token-alert.test.tsx b/packages/web/src/test/missing-token-alert.test.tsx
new file mode 100644
index 0000000..47018b5
--- /dev/null
+++ b/packages/web/src/test/missing-token-alert.test.tsx
@@ -0,0 +1,176 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { DemoProvider } from "@/context/DemoContext";
+import { MetadataProvider } from "@/context/MetadataContext";
+import { saveStore, updateInstance } from "@/lib/config";
+import { routeTree } from "@/routeTree.gen";
+
+const { httpFetch } = vi.hoisted(() => ({ httpFetch: vi.fn() }));
+vi.mock("@/lib/http", () => ({ httpFetch }));
+
+const INSTANCE_ID = "inst-1";
+const FAKE_TOKEN = "test-token";
+
+function jsonResponse(
+ status: number,
+ body: unknown = { items: [], total: 0, page: 1, size: 1, pages: 0 },
+) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+function seedInstance(token = "") {
+ saveStore({
+ instances: [
+ {
+ id: INSTANCE_ID,
+ name: "Local",
+ baseUrl: "http://localhost:8000",
+ token,
+ },
+ ],
+ activeId: INSTANCE_ID,
+ });
+}
+
+function renderApp(initialPath = "/") {
+ const router = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: [initialPath] }),
+ });
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return {
+ qc,
+ ...render(
+
+
+
+ {/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
+
+
+
+ ,
+ ),
+ };
+}
+
+describe("missing token warning", () => {
+ afterEach(() => {
+ httpFetch.mockReset();
+ localStorage.clear();
+ });
+
+ it("shows a visible alert when health is 401 and no token is configured", async () => {
+ seedInstance();
+ httpFetch.mockResolvedValue(jsonResponse(401));
+ renderApp();
+ expect(await screen.findByRole("alert")).toBeInTheDocument();
+ });
+
+ it("shows a visible alert when health is 403 and no token is configured", async () => {
+ seedInstance();
+ httpFetch.mockResolvedValue(jsonResponse(403));
+ renderApp();
+ expect(await screen.findByRole("alert")).toBeInTheDocument();
+ });
+
+ it("states that the Honcho instance requires a token and none is configured", async () => {
+ seedInstance();
+ httpFetch.mockResolvedValue(jsonResponse(401));
+ renderApp();
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ /requires an authentication token[\s\S]*none is configured/i,
+ );
+ });
+
+ it("links the warning to instance settings", async () => {
+ seedInstance();
+ httpFetch.mockResolvedValue(jsonResponse(401));
+ renderApp();
+ const action = await screen.findByRole("link", { name: /add token in settings/i });
+ expect(action).toHaveAttribute("href", "/settings");
+ });
+
+ it("opens settings from the warning action", async () => {
+ const user = userEvent.setup();
+ seedInstance();
+ httpFetch.mockResolvedValue(jsonResponse(401));
+ renderApp();
+ await user.click(await screen.findByRole("link", { name: /add token in settings/i }));
+ expect(await screen.findByText(/Manage your Honcho connections/i)).toBeInTheDocument();
+ });
+
+ it("does not warn when a public instance responds 200 without a token", async () => {
+ seedInstance();
+ httpFetch.mockResolvedValue(jsonResponse(200));
+ renderApp();
+ await screen.findByLabelText(/Connection status: Connected/i);
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("does not treat a generic server error as a missing token", async () => {
+ seedInstance();
+ httpFetch.mockResolvedValue(jsonResponse(500));
+ renderApp();
+ await screen.findByLabelText(/Connection status: Unreachable/i);
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("does not warn that a token is missing when one is already configured", async () => {
+ seedInstance(FAKE_TOKEN);
+ httpFetch.mockResolvedValue(jsonResponse(401));
+ renderApp();
+ await screen.findByLabelText(/Connection status: Auth required/i);
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("keeps the sidebar health indicator when auth is required", async () => {
+ seedInstance();
+ httpFetch.mockResolvedValue(jsonResponse(401));
+ renderApp();
+ await screen.findByRole("alert");
+ expect(screen.getByLabelText(/Connection status: Auth required/i)).toBeInTheDocument();
+ });
+
+ it("hides the warning after a token is saved and health succeeds", async () => {
+ seedInstance();
+ httpFetch.mockResolvedValue(jsonResponse(401));
+ renderApp();
+ await screen.findByRole("alert");
+
+ httpFetch.mockResolvedValue(jsonResponse(200));
+ act(() => {
+ updateInstance(INSTANCE_ID, { token: FAKE_TOKEN });
+ window.dispatchEvent(new Event("openconcho:instances-changed"));
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+ });
+
+ it("does not put the configured token into the document", async () => {
+ seedInstance(FAKE_TOKEN);
+ httpFetch.mockResolvedValue(jsonResponse(401));
+ renderApp();
+ await screen.findByLabelText(/Connection status: Auth required/i);
+ expect(document.body.textContent).not.toContain(FAKE_TOKEN);
+ });
+
+ it("does not put the configured token into query cache keys", async () => {
+ seedInstance(FAKE_TOKEN);
+ httpFetch.mockResolvedValue(jsonResponse(401));
+ const { qc } = renderApp();
+ await screen.findByLabelText(/Connection status: Auth required/i);
+ const cacheKeys = qc
+ .getQueryCache()
+ .getAll()
+ .map((query) => JSON.stringify(query.queryKey));
+ expect(cacheKeys.join("\n")).not.toContain(FAKE_TOKEN);
+ });
+});