Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/web/src/components/shared/MissingTokenAlert.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="alert"
className="sticky top-0 z-20 flex flex-col sm:flex-row sm:items-center gap-3 px-4 py-3"
style={{
background: COLOR.warningDim,
borderBottom: `1px solid ${COLOR.warningBorder}`,
}}
>
<div className="flex items-start gap-2 min-w-0 flex-1">
<AlertTriangle
className="w-4 h-4 shrink-0 mt-0.5"
style={{ color: COLOR.warning }}
strokeWidth={2}
aria-hidden="true"
/>
<div className="min-w-0">
<Body className="font-medium" style={{ color: COLOR.warning }}>
{TITLE}
</Body>
<Caption as="p" className="mt-0.5">
{BODY}
</Caption>
</div>
</div>
<Link
to="/settings"
className="text-sm font-medium px-3 py-1.5 rounded-lg shrink-0 self-start sm:self-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-[var(--bg)]"
style={{
color: COLOR.warning,
border: `1px solid ${COLOR.warningBorder}`,
background: "var(--surface)",
}}
>
{ACTION}
</Link>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -18,6 +19,7 @@ function RootLayout() {
>
<Sidebar />
<main className="flex-1 overflow-auto" style={{ position: "relative", zIndex: 1 }}>
<MissingTokenAlert />
<Outlet />
</main>
</div>
Expand Down
6 changes: 6 additions & 0 deletions packages/web/src/test/check-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }),
Expand Down
176 changes: 176 additions & 0 deletions packages/web/src/test/missing-token-alert.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={qc}>
<DemoProvider>
<MetadataProvider>
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
<RouterProvider router={router as any} />
</MetadataProvider>
</DemoProvider>
</QueryClientProvider>,
),
};
}

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