From 0ff60a79d10725698202f774f7806866c24a4cb2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 15:34:06 -0700 Subject: [PATCH] FN-7243: focus auth token recovery on unauthorized Focus daemon authorization failures on auth-token recovery instead of engine remediation. - Pass auth-token recovery state into dashboard banners so engine unavailable and status remediation banners stay hidden while recovery is open. - Improve auth recovery dialog focus styling and tests for unauthorized daemon responses. - Add release notes for focused auth-token recovery behavior. Files changed: .changeset/auth-token-recovery-focus.md | 7 ++ .changeset/fn-7243-auth-recovery-banner.md | 7 ++ packages/dashboard/app/App.tsx | 1 + packages/dashboard/app/__tests__/auth.test.ts | 31 ++++- .../app/components/AuthTokenRecoveryDialog.css | 7 +- .../__tests__/AuthTokenRecoveryDialog.test.tsx | 41 +++++- .../app/components/dashboard/DashboardBanners.tsx | 14 ++- .../dashboard/__tests__/DashboardBanners.test.tsx | 138 ++++++++++++++++++++- .../dashboard/app/components/dashboard/types.ts | 1 + .../hooks/__tests__/useAuthTokenRecovery.test.ts | 9 +- 10 files changed, 238 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-7243 Fusion-Task-Lineage: 8292080d-a310-4fdc-9b5f-baa17a592bad Co-authored-by: Fusion (runfusion.ai) --- .changeset/auth-token-recovery-focus.md | 7 + .changeset/fn-7243-auth-recovery-banner.md | 7 + packages/dashboard/app/App.tsx | 1 + packages/dashboard/app/__tests__/auth.test.ts | 33 ++++- .../components/AuthTokenRecoveryDialog.css | 7 +- .../AuthTokenRecoveryDialog.test.tsx | 41 +++++- .../components/dashboard/DashboardBanners.tsx | 14 +- .../__tests__/DashboardBanners.test.tsx | 138 +++++++++++++++++- .../app/components/dashboard/types.ts | 1 + .../__tests__/useAuthTokenRecovery.test.ts | 9 +- 10 files changed, 239 insertions(+), 19 deletions(-) create mode 100644 .changeset/auth-token-recovery-focus.md create mode 100644 .changeset/fn-7243-auth-recovery-banner.md 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");