diff --git a/ui/e2e/auth.spec.ts b/ui/e2e/auth.spec.ts
index 84c4842..4846a05 100644
--- a/ui/e2e/auth.spec.ts
+++ b/ui/e2e/auth.spec.ts
@@ -34,34 +34,34 @@ const test = base.extend<{ unauthedPage: Page }>({
},
});
-// TODO(#66): re-enable these once the UI renders a visible "sign in" /
-// "authentication required" affordance on 401. Today apiFetch throws an
-// ApiErrorResponse that bubbles into React Query error states without a
-// recognisable auth-copy surface. See flake-register issue #66.
test.describe("unauthed API", () => {
- test.fixme(
- "home surfaces an auth-required affordance when /api/* returns 401",
- async ({ unauthedPage: page }) => {
- await page.goto("/");
- await expect(page.locator("main#main")).toBeVisible();
- await expect(
- page
- .getByText(/sign in|authenticat|authori|session expired|please log in/i)
- .first(),
- ).toBeVisible({ timeout: 5_000 });
- },
- );
+ test("home surfaces an auth-required affordance when /api/* returns 401", async ({
+ unauthedPage: page,
+ }) => {
+ await page.goto("/");
+ await expect(page.locator("main#main")).toBeVisible();
+ await expect(page.getByTestId("auth-required-banner")).toBeVisible({
+ timeout: 5_000,
+ });
+ await expect(
+ page
+ .getByText(/sign in|authenticat|authori|session expired|please log in/i)
+ .first(),
+ ).toBeVisible();
+ });
- test.fixme(
- "navigating to /notes with 401 shows the same affordance",
- async ({ unauthedPage: page }) => {
- await page.goto("/notes");
- await expect(page.locator("main#main")).toBeVisible();
- await expect(
- page
- .getByText(/sign in|authenticat|authori|session expired|please log in/i)
- .first(),
- ).toBeVisible({ timeout: 5_000 });
- },
- );
+ test("navigating to /notes with 401 shows the same affordance", async ({
+ unauthedPage: page,
+ }) => {
+ await page.goto("/notes");
+ await expect(page.locator("main#main")).toBeVisible();
+ await expect(page.getByTestId("auth-required-banner")).toBeVisible({
+ timeout: 5_000,
+ });
+ await expect(
+ page
+ .getByText(/sign in|authenticat|authori|session expired|please log in/i)
+ .first(),
+ ).toBeVisible();
+ });
});
diff --git a/ui/src/components/layout/AuthRequiredBanner.tsx b/ui/src/components/layout/AuthRequiredBanner.tsx
new file mode 100644
index 0000000..91cbd12
--- /dev/null
+++ b/ui/src/components/layout/AuthRequiredBanner.tsx
@@ -0,0 +1,42 @@
+import { ShieldAlert } from "lucide-react";
+import { useAuthStore } from "@/stores/auth";
+
+// AuthRequiredBanner renders a visible "Authentication required"
+// affordance when the API has returned 401 anywhere in the app.
+// Mounted inside so smoke tests that scope to that
+// landmark always find the copy.
+//
+// Copy keywords ("Sign in", "authentication required", "session") are
+// intentionally aligned with ui/e2e/auth.spec.ts so the Playwright
+// smoke can match without embedding brittle selectors.
+export function AuthRequiredBanner() {
+ const authRequired = useAuthStore((s) => s.authRequired);
+ if (!authRequired) return null;
+ return (
+
+
+
+
+
Sign in required
+
+ Your session has expired or is missing. Please sign in again —
+ run docsiq login on the server, or reload the page
+ after authentication is re-established.
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/layout/Providers.tsx b/ui/src/components/layout/Providers.tsx
index e029920..981bb47 100644
--- a/ui/src/components/layout/Providers.tsx
+++ b/ui/src/components/layout/Providers.tsx
@@ -1,12 +1,33 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ MutationCache,
+ QueryCache,
+ QueryClient,
+ QueryClientProvider,
+} from "@tanstack/react-query";
import { useEffect, useState, type ReactNode } from "react";
import { BrowserRouter } from "react-router-dom";
import { useUIStore } from "@/stores/ui";
+import { useAuthStore } from "@/stores/auth";
+
+// Global 401 gate: any /api/* fetch that throws an ApiErrorResponse
+// with status === 401 flips the auth store so AuthRequiredBanner can
+// render a visible "Sign in required" affordance. Wired to BOTH
+// QueryCache and MutationCache — a 401 on a write action (note
+// create/update/delete) must surface the banner just the same as a
+// read-path failure.
+function gateUnauthorized(error: unknown) {
+ const status = (error as { status?: number })?.status ?? 0;
+ if (status === 401) {
+ useAuthStore.getState().signalUnauthorized();
+ }
+}
export function Providers({ children }: { children: ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
+ queryCache: new QueryCache({ onError: gateUnauthorized }),
+ mutationCache: new MutationCache({ onError: gateUnauthorized }),
defaultOptions: {
queries: {
staleTime: 30_000,
diff --git a/ui/src/components/layout/Shell.tsx b/ui/src/components/layout/Shell.tsx
index 2c1a75c..d714dad 100644
--- a/ui/src/components/layout/Shell.tsx
+++ b/ui/src/components/layout/Shell.tsx
@@ -2,6 +2,7 @@ import { type ReactNode, useCallback, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
+import { AuthRequiredBanner } from "./AuthRequiredBanner";
import { SkipLink } from "./SkipLink";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { useHotkey } from "@/hooks/useHotkey";
@@ -60,6 +61,7 @@ export function Shell({ children }: { children: ReactNode }) {
tabIndex={-1}
className="flex flex-1 flex-col"
>
+
{children}
diff --git a/ui/src/components/layout/__tests__/AuthRequiredBanner.test.tsx b/ui/src/components/layout/__tests__/AuthRequiredBanner.test.tsx
new file mode 100644
index 0000000..7b7177e
--- /dev/null
+++ b/ui/src/components/layout/__tests__/AuthRequiredBanner.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from "@testing-library/react";
+import { act } from "react";
+import { afterEach, describe, expect, it } from "vitest";
+import { AuthRequiredBanner } from "../AuthRequiredBanner";
+import { useAuthStore } from "@/stores/auth";
+
+afterEach(() => {
+ // Store is module-level; reset between tests or flipped state leaks.
+ act(() => useAuthStore.getState().clear());
+});
+
+describe("AuthRequiredBanner", () => {
+ it("renders nothing while authRequired is false", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders a visible sign-in affordance once authRequired flips", () => {
+ render();
+ act(() => useAuthStore.getState().signalUnauthorized());
+
+ const banner = screen.getByTestId("auth-required-banner");
+ expect(banner).toBeInTheDocument();
+ expect(banner).toHaveAttribute("role", "alert");
+ expect(banner).toHaveAttribute("aria-live", "assertive");
+ expect(screen.getByText(/sign in required/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(/session has expired|please sign in/i),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /reload/i })).toBeInTheDocument();
+ });
+});
diff --git a/ui/src/components/layout/__tests__/Providers.gate.test.tsx b/ui/src/components/layout/__tests__/Providers.gate.test.tsx
new file mode 100644
index 0000000..bbd0cc9
--- /dev/null
+++ b/ui/src/components/layout/__tests__/Providers.gate.test.tsx
@@ -0,0 +1,86 @@
+import { render } from "@testing-library/react";
+import { QueryClient, QueryClientProvider, useMutation, useQuery } from "@tanstack/react-query";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { Providers } from "../Providers";
+import { useAuthStore } from "@/stores/auth";
+import { ApiErrorResponse } from "@/lib/api-client";
+
+// The real Providers wires QueryCache + MutationCache `onError` to the
+// auth store. Rather than render Providers (which pulls in the full
+// router + shell), we reach into the same factory semantics by
+// inlining the cache hooks — and assert the banner store flips for
+// BOTH query and mutation 401s.
+
+function unauthorized(): ApiErrorResponse {
+ return new ApiErrorResponse(401, { error: "unauthenticated" });
+}
+
+function TriggerQuery() {
+ useQuery({
+ queryKey: ["probe"],
+ queryFn: () => {
+ throw unauthorized();
+ },
+ retry: false,
+ });
+ return null;
+}
+
+function TriggerMutation() {
+ const m = useMutation({
+ mutationFn: async () => {
+ throw unauthorized();
+ },
+ });
+ if (!m.isPending && !m.isError && !m.isSuccess) m.mutate();
+ return null;
+}
+
+function mountWithRealProviders(children: React.ReactNode) {
+ return render({children});
+}
+
+afterEach(() => useAuthStore.getState().clear());
+
+describe("Providers auth gate", () => {
+ it("flips authRequired when a query throws 401", async () => {
+ mountWithRealProviders();
+ await vi.waitFor(() => {
+ expect(useAuthStore.getState().authRequired).toBe(true);
+ });
+ });
+
+ it("flips authRequired when a mutation throws 401", async () => {
+ mountWithRealProviders();
+ await vi.waitFor(() => {
+ expect(useAuthStore.getState().authRequired).toBe(true);
+ });
+ });
+
+ it("does not flip for non-401 errors", async () => {
+ // Build a sibling QueryClient wired the same way so we can
+ // simulate a 500 without waiting for the sentinel flag at the
+ // store. If the 500 ever flipped the flag we'd fail the prior
+ // assertions too, so this is belt-and-braces.
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ function Child() {
+ useQuery({
+ queryKey: ["nope"],
+ queryFn: () => {
+ throw new ApiErrorResponse(500, { error: "boom" });
+ },
+ retry: false,
+ });
+ return null;
+ }
+ render(
+
+
+ ,
+ );
+ // A 500 never reaches Providers' gate (we used a fresh client),
+ // so authRequired must stay false across one microtask flush.
+ await new Promise((r) => setTimeout(r, 10));
+ expect(useAuthStore.getState().authRequired).toBe(false);
+ });
+});
diff --git a/ui/src/stores/__tests__/auth.test.ts b/ui/src/stores/__tests__/auth.test.ts
new file mode 100644
index 0000000..4ec6391
--- /dev/null
+++ b/ui/src/stores/__tests__/auth.test.ts
@@ -0,0 +1,17 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { useAuthStore } from "../auth";
+
+afterEach(() => useAuthStore.getState().clear());
+
+describe("useAuthStore", () => {
+ it("starts clean", () => {
+ expect(useAuthStore.getState().authRequired).toBe(false);
+ });
+
+ it("signalUnauthorized flips the flag; clear resets it", () => {
+ useAuthStore.getState().signalUnauthorized();
+ expect(useAuthStore.getState().authRequired).toBe(true);
+ useAuthStore.getState().clear();
+ expect(useAuthStore.getState().authRequired).toBe(false);
+ });
+});
diff --git a/ui/src/stores/auth.ts b/ui/src/stores/auth.ts
new file mode 100644
index 0000000..6adcbdc
--- /dev/null
+++ b/ui/src/stores/auth.ts
@@ -0,0 +1,17 @@
+import { create } from "zustand";
+
+// authRequired flips to true the first time any React Query sees a 401
+// from /api/* or /mcp/*. Stays true until the user takes a sign-in
+// action (e.g. reload after OOB provisioning). Not persisted — if the
+// tab closes, the next session's cookies dictate the state afresh.
+interface AuthState {
+ authRequired: boolean;
+ signalUnauthorized: () => void;
+ clear: () => void;
+}
+
+export const useAuthStore = create()((set) => ({
+ authRequired: false,
+ signalUnauthorized: () => set({ authRequired: true }),
+ clear: () => set({ authRequired: false }),
+}));