diff --git a/.changeset/auth-token-recovery-focus.md b/.changeset/auth-token-recovery-focus.md
new file mode 100644
index 0000000000..e843a367b6
--- /dev/null
+++ b/.changeset/auth-token-recovery-focus.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Show focused auth-token recovery when daemon authorization expires.
+category: fix
+dev: Handles exact daemon 401 recovery, focuses the replacement-token input, and suppresses engine remediation while recovery is open.
diff --git a/.changeset/fn-7243-auth-recovery-banner.md b/.changeset/fn-7243-auth-recovery-banner.md
new file mode 100644
index 0000000000..32d4310903
--- /dev/null
+++ b/.changeset/fn-7243-auth-recovery-banner.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Hide engine remediation banners while daemon auth token recovery is open.
+category: fix
+dev: Threads authTokenRecoveryOpen through DashboardBanners to suppress EngineStatusBanner and EngineUnavailableBanner.
diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx
index 365ad694b6..696bb109d0 100644
--- a/packages/dashboard/app/App.tsx
+++ b/packages/dashboard/app/App.tsx
@@ -1296,6 +1296,7 @@ function AppInner() {
const dashboardBannersProps: DashboardBannersProps = {
viewMode,
currentProject,
+ authTokenRecoveryOpen,
isTestMode,
dashboardHealth,
setDashboardHealth,
diff --git a/packages/dashboard/app/__tests__/auth.test.ts b/packages/dashboard/app/__tests__/auth.test.ts
index 1096cf0b8f..fe2968a2a3 100644
--- a/packages/dashboard/app/__tests__/auth.test.ts
+++ b/packages/dashboard/app/__tests__/auth.test.ts
@@ -201,14 +201,24 @@ describe("installAuthFetch", () => {
window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, eventHandler);
});
- it("does not fire the recovery signal for unrelated 401 payloads", async () => {
+ it("fires recovery only for the exact daemon-auth 401 shape on same-origin /api requests", async () => {
window.localStorage.setItem("fn.authToken", "stale-token");
- window.fetch = vi.fn(async () => {
- return new Response(JSON.stringify({ error: "Unauthorized", message: "Project auth required" }), {
+ const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
+ const url = typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.toString()
+ : input.url;
+ const payload = url.includes("project-auth")
+ ? { error: "Unauthorized", message: "Project auth required" }
+ : { error: "Unauthorized", message: "Valid bearer token required" };
+
+ return new Response(JSON.stringify(payload), {
status: 401,
headers: { "content-type": "application/json" },
});
- }) as unknown as typeof window.fetch;
+ });
+ window.fetch = fetchSpy as unknown as typeof window.fetch;
const { installAuthFetch, AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } = await loadAuthModule();
installAuthFetch();
@@ -216,12 +226,21 @@ describe("installAuthFetch", () => {
const eventHandler = vi.fn();
window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, eventHandler);
- const response = await fetch("/api/tasks");
- expect(await response.json()).toEqual({ error: "Unauthorized", message: "Project auth required" });
-
+ await fetch("https://example.com/api/tasks");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(eventHandler).not.toHaveBeenCalled();
+ const projectAuthResponse = await fetch("/api/project-auth");
+ expect(await projectAuthResponse.json()).toEqual({ error: "Unauthorized", message: "Project auth required" });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(eventHandler).not.toHaveBeenCalled();
+
+ const daemonAuthResponse = await fetch("/api/tasks");
+ expect(await daemonAuthResponse.json()).toEqual({ error: "Unauthorized", message: "Valid bearer token required" });
+ await vi.waitFor(() => {
+ expect(eventHandler).toHaveBeenCalledTimes(1);
+ });
+
window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, eventHandler);
});
diff --git a/packages/dashboard/app/components/AuthTokenRecoveryDialog.css b/packages/dashboard/app/components/AuthTokenRecoveryDialog.css
index 5ed6593ab7..f4164da23f 100644
--- a/packages/dashboard/app/components/AuthTokenRecoveryDialog.css
+++ b/packages/dashboard/app/components/AuthTokenRecoveryDialog.css
@@ -1,6 +1,7 @@
-.auth-token-recovery-overlay {
- z-index: 210;
-}
+/*
+FNXC:AuthTokenRecovery 2026-06-29-00:00:
+The daemon-auth recovery dialog is blocking but must use the shared modal-overlay stacking contract. Do not add auth-specific z-index rules; the dashboard banner stack is suppressed separately while recovery is open.
+*/
.auth-token-recovery-header h3 {
margin: 0;
diff --git a/packages/dashboard/app/components/__tests__/AuthTokenRecoveryDialog.test.tsx b/packages/dashboard/app/components/__tests__/AuthTokenRecoveryDialog.test.tsx
index 4c7f91f01e..7ed9d7bf39 100644
--- a/packages/dashboard/app/components/__tests__/AuthTokenRecoveryDialog.test.tsx
+++ b/packages/dashboard/app/components/__tests__/AuthTokenRecoveryDialog.test.tsx
@@ -1,3 +1,4 @@
+import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { AuthTokenRecoveryDialog } from "../AuthTokenRecoveryDialog";
@@ -26,7 +27,7 @@ describe("AuthTokenRecoveryDialog", () => {
expect(screen.queryByRole("dialog", { name: "Authentication token required" })).toBeNull();
});
- it("renders a blocking dialog with disabled set button until token is entered", () => {
+ it("renders a blocking shared-modal dialog, focuses the token input, and disables set until input is populated", () => {
render();
const dialog = screen.getByRole("dialog", { name: "Authentication token required" });
@@ -40,15 +41,49 @@ describe("AuthTokenRecoveryDialog", () => {
throw new Error("Expected auth token recovery overlay");
}
- expect(dialog.className).toContain("modal-md");
+ expect(overlay.classList.contains("modal-overlay")).toBe(true);
+ expect(overlay.classList.contains("open")).toBe(true);
+ expect(dialog.classList.contains("modal")).toBe(true);
+ expect(dialog.classList.contains("modal-md")).toBe(true);
+
+ const input = screen.getByLabelText("Replacement token");
+ expect(document.activeElement).toBe(input);
const setTokenButton = screen.getByRole("button", { name: "Set token and reload" });
expect(setTokenButton).toBeDisabled();
- fireEvent.change(screen.getByLabelText("Replacement token"), { target: { value: "abc123" } });
+ fireEvent.change(input, { target: { value: " " } });
+ expect(setTokenButton).toBeDisabled();
+
+ fireEvent.change(input, { target: { value: "abc123" } });
expect(setTokenButton).toBeEnabled();
});
+ it("focuses the token input when recovery opens after the app shell was already mounted", () => {
+ const { rerender } = render();
+
+ rerender();
+
+ const tokenInput = screen.getByLabelText("Replacement token");
+ expect(screen.getByRole("dialog", { name: "Authentication token required" })).toBeInTheDocument();
+ expect(tokenInput).toBe(document.activeElement);
+ });
+
+ it("keeps a single blocking dialog when duplicate open signals rerender it", () => {
+ const { rerender } = render();
+
+ rerender();
+
+ expect(screen.getAllByRole("dialog", { name: "Authentication token required" })).toHaveLength(1);
+ expect(document.querySelectorAll(".auth-token-recovery-overlay")).toHaveLength(1);
+ });
+
+ it("does not define auth-specific modal layering outside the shared modal classes", () => {
+ const css = readFileSync("app/components/AuthTokenRecoveryDialog.css", "utf8");
+
+ expect(css).not.toMatch(/auth-token-recovery-overlay\s*\{[^}]*z-index/s);
+ });
+
it("trims and stores replacement token before reloading", () => {
render();
diff --git a/packages/dashboard/app/components/dashboard/DashboardBanners.tsx b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx
index a79c3f3a87..90a69c2826 100644
--- a/packages/dashboard/app/components/dashboard/DashboardBanners.tsx
+++ b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx
@@ -27,6 +27,7 @@ function isMailboxApprovalCandidate(candidate: DashboardBannersProps["approvalBa
export function DashboardBanners({
viewMode,
currentProject,
+ authTokenRecoveryOpen,
isTestMode,
dashboardHealth,
setDashboardHealth,
@@ -67,15 +68,22 @@ export function DashboardBanners({
}: DashboardBannersProps) {
/* FNXC:DashboardBanners 2026-06-26-00:00: The Open Mailbox approval banner is gated by an approval: candidate from a real ApprovalRequest. The count floor remains only for the approval-SSE/count-refresh race and must not fabricate a mailbox request for task awaiting-approval states. */
const showMailboxApprovalBanner = isMailboxApprovalCandidate(approvalBannerCandidate);
+ /* FNXC:AuthRecovery 2026-06-29-00:00: Daemon-auth token recovery owns unauthorized remediation while its blocking dialog is open. Suppress engine remediation banners in parallel so operators fix the token once without seeing stale engine restart/start controls or live-region shells. */
+ const showEngineRemediationBanners = !authTokenRecoveryOpen;
+
return (
<>
{viewMode === "project" && currentProject && (
<>
-
- {/* FNXC:EngineStatusBanner 2026-06-22-00:00: Project-scoped engine remediation belongs in the same project-only banner guard family as the existing operational notices, and the key resets polling immediately when the user switches projects. */}
-
+ {showEngineRemediationBanners && (
+
+ )}
+ {showEngineRemediationBanners && (
+ /* FNXC:EngineStatusBanner 2026-06-22-00:00: Project-scoped engine remediation belongs in the same project-only banner guard family as the existing operational notices, and the key resets polling immediately when the user switches projects. */
+
+ )}
openSettingsWithNav("authentication" as SectionId)}
/>
diff --git a/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx
index e13f511400..53153ed249 100644
--- a/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx
+++ b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx
@@ -2,11 +2,27 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { AiSessionSummary } from "../../../api";
import type { ModalManager } from "../../../hooks/useModalManager";
+import { AuthTokenRecoveryDialog } from "../../AuthTokenRecoveryDialog";
import type { DashboardBannersProps } from "../types";
vi.mock("../../TestModeBanner", () => ({ TestModeBanner: () => null }));
-vi.mock("../../EngineUnavailableBanner", () => ({ EngineUnavailableBanner: () => null }));
-vi.mock("../../EngineStatusBanner", () => ({ EngineStatusBanner: () => null }));
+vi.mock("../../EngineUnavailableBanner", () => ({
+ EngineUnavailableBanner: ({ isVisible }: { isVisible: boolean }) => (
+ isVisible ? (
+
+ ) : null
+ ),
+}));
+vi.mock("../../EngineStatusBanner", () => ({
+ EngineStatusBanner: ({ projectId }: { projectId: string }) => (
+
+ Engine status for {projectId}
+
+
+ ),
+}));
vi.mock("../../OAuthReloginBanner", () => ({ OAuthReloginBanner: () => null }));
vi.mock("../../CliBinaryInstallBanner", () => ({ CliBinaryInstallBanner: () => null }));
vi.mock("../../OnboardingResumeCard", () => ({ OnboardingResumeCard: () => null }));
@@ -138,6 +154,7 @@ function buildProps(overrides: Partial = {}): DashboardBa
return {
viewMode: "project",
currentProject: { id: "proj-1", name: "Project", path: "/tmp/project" } as DashboardBannersProps["currentProject"],
+ authTokenRecoveryOpen: false,
isTestMode: false,
dashboardHealth: null,
setDashboardHealth: vi.fn(),
@@ -183,6 +200,123 @@ function querySessionBanner(): HTMLElement | null {
return screen.queryByRole("region", { name: /AI sessions needing input or failed/i });
}
+function unavailableEngineHealth(): DashboardBannersProps["dashboardHealth"] {
+ return {
+ status: "degraded",
+ engine: { available: false, status: "unavailable" },
+ database: { healthy: true, corruptionDetected: false, corruptionErrors: [], lastCheckedAt: null },
+ taskIdIntegrity: { status: "ok" },
+ } as DashboardBannersProps["dashboardHealth"];
+}
+
+function AuthRecoveryBannerShell({
+ open,
+ currentProject = buildProps().currentProject,
+ dashboardHealth = unavailableEngineHealth(),
+}: {
+ open: boolean;
+ currentProject?: DashboardBannersProps["currentProject"];
+ dashboardHealth?: DashboardBannersProps["dashboardHealth"] | undefined;
+}) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function expectNoEngineRemediationShell(): void {
+ expect(screen.queryByTestId("engine-status-banner")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("engine-unavailable-banner")).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /start engine/i })).not.toBeInTheDocument();
+ expect(
+ document.querySelector(
+ '[data-testid="engine-status-banner"][aria-live="polite"], [data-testid="engine-unavailable-banner"][aria-live="polite"]',
+ ),
+ ).not.toBeInTheDocument();
+}
+
+describe("DashboardBanners engine remediation visibility", () => {
+ /*
+ FNXC:AuthRecovery 2026-06-29-00:00:
+ FN-7243 surface enumeration: DashboardBanners is the app-shell project banner stack that mounts EngineStatusBanner and EngineUnavailableBanner. Auth token recovery must suppress both engine-remediation components while preserving the existing project/currentProject guard, so unauthorized daemon-token recovery does not leave empty aria-live regions, start buttons, or banner shells behind.
+ */
+ it("shows only the auth-token recovery dialog when unauthorized recovery opens over visible engine remediation", () => {
+ const { rerender } = render();
+
+ expect(screen.getByTestId("engine-status-banner")).toBeInTheDocument();
+ expect(screen.getByTestId("engine-unavailable-banner")).toBeInTheDocument();
+ expect(screen.getAllByRole("button", { name: /start engine/i })).toHaveLength(2);
+
+ rerender();
+
+ const dialog = screen.getByRole("dialog", { name: "Authentication token required" });
+ const tokenInput = screen.getByLabelText("Replacement token");
+ expect(dialog).toBeInTheDocument();
+ expect(tokenInput).toBeInTheDocument();
+ expect(document.activeElement).toBe(tokenInput);
+ expectNoEngineRemediationShell();
+ });
+
+ it("does not create stale engine output when auth recovery is open without a current project or health", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getAllByRole("dialog", { name: "Authentication token required" })).toHaveLength(1);
+ expect(screen.getByLabelText("Replacement token")).toBe(document.activeElement);
+ expectNoEngineRemediationShell();
+ });
+
+ it("suppresses engine remediation banners and shells while auth token recovery is open", () => {
+ render(
+ ,
+ );
+
+ expectNoEngineRemediationShell();
+ });
+
+ it("mounts engine remediation banners when auth token recovery is closed and engine requires attention", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("engine-status-banner")).toBeInTheDocument();
+ expect(screen.getByTestId("engine-unavailable-banner")).toBeInTheDocument();
+ expect(screen.getAllByRole("button", { name: /start engine/i })).toHaveLength(2);
+ });
+
+ it("preserves the current project guard while auth recovery is closed", () => {
+ render();
+
+ expect(screen.queryByTestId("engine-status-banner")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("engine-unavailable-banner")).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /start engine/i })).not.toBeInTheDocument();
+ });
+});
+
describe("DashboardBanners session notification visibility", () => {
/*
FNXC:SessionBanner 2026-06-25-00:00:
diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts
index 6b4a79ec5a..a2ff9a09a2 100644
--- a/packages/dashboard/app/components/dashboard/types.ts
+++ b/packages/dashboard/app/components/dashboard/types.ts
@@ -238,6 +238,7 @@ export interface MainContentProps {
export interface DashboardBannersProps {
viewMode: ViewMode;
currentProject: ProjectInfo | null;
+ authTokenRecoveryOpen: boolean;
isTestMode: boolean;
dashboardHealth: DashboardHealthResponse | null;
setDashboardHealth: Dispatch>;
diff --git a/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts
index 54eec1cde2..0c8f175b71 100644
--- a/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts
@@ -7,7 +7,7 @@ describe("useAuthTokenRecovery", () => {
afterEach(() => {
vi.restoreAllMocks();
});
- it("opens when the daemon auth-failure event fires", () => {
+ it("opens on daemon auth-failure events and stays open for duplicate signals", () => {
const { result } = renderHook(() => useAuthTokenRecovery());
expect(result.current.open).toBe(false);
@@ -17,6 +17,13 @@ describe("useAuthTokenRecovery", () => {
});
expect(result.current.open).toBe(true);
+
+ act(() => {
+ window.dispatchEvent(new Event(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT));
+ window.dispatchEvent(new Event(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT));
+ });
+
+ expect(result.current.open).toBe(true);
});
it("removes the daemon auth-failure listener on unmount", () => {
const addSpy = vi.spyOn(window, "addEventListener");