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:
7
.changeset/auth-token-recovery-focus.md
Normal file
7
.changeset/auth-token-recovery-focus.md
Normal 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.
|
||||
7
.changeset/fn-7243-auth-recovery-banner.md
Normal file
7
.changeset/fn-7243-auth-recovery-banner.md
Normal 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.
|
||||
@@ -1296,6 +1296,7 @@ function AppInner() {
|
||||
const dashboardBannersProps: DashboardBannersProps = {
|
||||
viewMode,
|
||||
currentProject,
|
||||
authTokenRecoveryOpen,
|
||||
isTestMode,
|
||||
dashboardHealth,
|
||||
setDashboardHealth,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user