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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 15:34:06 -07:00
parent c6c4a003f9
commit 0ff60a79d1
10 changed files with 239 additions and 19 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -1296,6 +1296,7 @@ function AppInner() {
const dashboardBannersProps: DashboardBannersProps = {
viewMode,
currentProject,
authTokenRecoveryOpen,
isTestMode,
dashboardHealth,
setDashboardHealth,

View File

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

View File

@@ -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;

View File

@@ -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(<AuthTokenRecoveryDialog open={true} />);
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(<AuthTokenRecoveryDialog open={false} />);
rerender(<AuthTokenRecoveryDialog open={true} />);
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(<AuthTokenRecoveryDialog open={true} />);
rerender(<AuthTokenRecoveryDialog open={true} />);
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(<AuthTokenRecoveryDialog open={true} />);

View File

@@ -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:<id> 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 && (
<>
<TestModeBanner isActive={isTestMode} />
<EngineUnavailableBanner isVisible={dashboardHealth?.engine?.available === false} />
{/* 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. */}
<EngineStatusBanner key={currentProject.id} projectId={currentProject.id} />
{showEngineRemediationBanners && (
<EngineUnavailableBanner isVisible={dashboardHealth?.engine?.available === false} />
)}
{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. */
<EngineStatusBanner key={currentProject.id} projectId={currentProject.id} />
)}
<OAuthReloginBanner
onReLogin={(_providerId) => openSettingsWithNav("authentication" as SectionId)}
/>

View File

@@ -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 ? (
<section role="status" aria-live="polite" data-testid="engine-unavailable-banner">
<button type="button">Start engine</button>
</section>
) : null
),
}));
vi.mock("../../EngineStatusBanner", () => ({
EngineStatusBanner: ({ projectId }: { projectId: string }) => (
<section role="status" aria-live="polite" data-testid="engine-status-banner">
<span>Engine status for {projectId}</span>
<button type="button">Start engine</button>
</section>
),
}));
vi.mock("../../OAuthReloginBanner", () => ({ OAuthReloginBanner: () => null }));
vi.mock("../../CliBinaryInstallBanner", () => ({ CliBinaryInstallBanner: () => null }));
vi.mock("../../OnboardingResumeCard", () => ({ OnboardingResumeCard: () => null }));
@@ -138,6 +154,7 @@ function buildProps(overrides: Partial<DashboardBannersProps> = {}): 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 (
<>
<DashboardBanners
{...buildProps({
authTokenRecoveryOpen: open,
currentProject,
dashboardHealth: dashboardHealth as DashboardBannersProps["dashboardHealth"],
sessionsNeedingInput: [],
})}
/>
<AuthTokenRecoveryDialog open={open} />
</>
);
}
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(<AuthRecoveryBannerShell open={false} />);
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(<AuthRecoveryBannerShell open={true} />);
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(
<AuthRecoveryBannerShell
open={true}
currentProject={null}
dashboardHealth={undefined}
/>,
);
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(
<DashboardBanners
{...buildProps({
authTokenRecoveryOpen: true,
dashboardHealth: unavailableEngineHealth(),
})}
/>,
);
expectNoEngineRemediationShell();
});
it("mounts engine remediation banners when auth token recovery is closed and engine requires attention", () => {
render(
<DashboardBanners
{...buildProps({
authTokenRecoveryOpen: false,
dashboardHealth: unavailableEngineHealth(),
})}
/>,
);
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(<DashboardBanners {...buildProps({ authTokenRecoveryOpen: false, currentProject: null })} />);
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:

View File

@@ -238,6 +238,7 @@ export interface MainContentProps {
export interface DashboardBannersProps {
viewMode: ViewMode;
currentProject: ProjectInfo | null;
authTokenRecoveryOpen: boolean;
isTestMode: boolean;
dashboardHealth: DashboardHealthResponse | null;
setDashboardHealth: Dispatch<SetStateAction<DashboardHealthResponse | null>>;

View File

@@ -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");