diff --git a/.changeset/fn-8301-pwa-auth-recovery.md b/.changeset/fn-8301-pwa-auth-recovery.md new file mode 100644 index 0000000000..f87b150d88 --- /dev/null +++ b/.changeset/fn-8301-pwa-auth-recovery.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore token recovery for installed PWAs after an unauthorized backend response. +category: fix +dev: Recovery reads the latched daemon-auth failure when the dashboard hook mounts. diff --git a/packages/dashboard/app/__tests__/auth.test.ts b/packages/dashboard/app/__tests__/auth.test.ts index fe2968a2a3..6966e2730a 100644 --- a/packages/dashboard/app/__tests__/auth.test.ts +++ b/packages/dashboard/app/__tests__/auth.test.ts @@ -201,6 +201,46 @@ describe("installAuthFetch", () => { window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, eventHandler); }); + it("reports daemon-auth failures through the getter and clears it with token changes", async () => { + window.fetch = vi.fn(async () => new Response( + JSON.stringify({ error: "Unauthorized", message: "Valid bearer token required" }), + { status: 401, headers: { "content-type": "application/json" } }, + )) as unknown as typeof window.fetch; + + const { + AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, + clearAuthToken, + hasDaemonAuthFailure, + installAuthFetch, + setAuthToken, + } = await loadAuthModule(); + installAuthFetch(); + + const waitForRecovery = () => new Promise((resolve) => { + const handleRecovery = () => { + window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleRecovery); + resolve(); + }; + window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleRecovery); + }); + + const firstRecovery = waitForRecovery(); + await fetch("/api/tasks"); + await firstRecovery; + expect(hasDaemonAuthFailure()).toBe(true); + + setAuthToken("replacement-token"); + expect(hasDaemonAuthFailure()).toBe(false); + + const secondRecovery = waitForRecovery(); + await fetch("/api/tasks?retry=1"); + await secondRecovery; + expect(hasDaemonAuthFailure()).toBe(true); + + clearAuthToken(); + expect(hasDaemonAuthFailure()).toBe(false); + }); + it("fires recovery only for the exact daemon-auth 401 shape on same-origin /api requests", async () => { window.localStorage.setItem("fn.authToken", "stale-token"); const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { diff --git a/packages/dashboard/app/auth.ts b/packages/dashboard/app/auth.ts index e46a84f36e..cd2dc03b03 100644 --- a/packages/dashboard/app/auth.ts +++ b/packages/dashboard/app/auth.ts @@ -110,6 +110,14 @@ export function getAuthToken(): string | undefined { return undefined; } +/* +FNXC:AuthTokenRecovery 2026-07-14-00:00: +Cold PWA and bare-URL starts can receive the one-shot daemon-auth 401 recovery event before React mounts its listener. Keep the latched failure queryable so the recovery hook can open the token dialog on mount instead of stranding the user on the backend error page. +*/ +export function hasDaemonAuthFailure(): boolean { + return daemonAuthFailureSignaled; +} + /** Persist a token for future dashboard API requests in this browser session. */ export function setAuthToken(token: string): void { cachedToken = token; diff --git a/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts index 0c8f175b71..4ad4a3bbf5 100644 --- a/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts @@ -1,9 +1,35 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderHook, act } from "@testing-library/react"; -import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth"; +import { + AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, + clearAuthToken, + hasDaemonAuthFailure, + installAuthFetch, + setAuthToken, +} from "../../auth"; import { useAuthTokenRecovery } from "../useAuthTokenRecovery"; +const originalFetch = window.fetch; + +function waitForDaemonAuthRecoveryEvent(): Promise { + return new Promise((resolve) => { + const handleRecovery = () => { + window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleRecovery); + resolve(); + }; + window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleRecovery); + }); +} + describe("useAuthTokenRecovery", () => { + beforeEach(() => { + clearAuthToken(); + window.localStorage.clear(); + window.history.replaceState({}, "", "/"); + window.fetch = originalFetch; + delete (window as Window & { __fnAuthFetchInstalled?: boolean }).__fnAuthFetchInstalled; + }); + afterEach(() => { vi.restoreAllMocks(); }); @@ -25,6 +51,37 @@ describe("useAuthTokenRecovery", () => { expect(result.current.open).toBe(true); }); + it("opens when a daemon-auth 401 latched before the hook mounts", async () => { + window.fetch = vi.fn(async () => new Response( + JSON.stringify({ error: "Unauthorized", message: "Valid bearer token required" }), + { status: 401, headers: { "content-type": "application/json" } }, + )) as unknown as typeof window.fetch; + installAuthFetch(); + + const recoveryEvent = waitForDaemonAuthRecoveryEvent(); + await fetch("/api/tasks"); + await recoveryEvent; + + expect(hasDaemonAuthFailure()).toBe(true); + const { result } = renderHook(() => useAuthTokenRecovery()); + expect(result.current.open).toBe(true); + }); + + it("does not open for a successful API response with a valid token", async () => { + setAuthToken("valid-token"); + window.fetch = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof window.fetch; + installAuthFetch(); + + await fetch("/api/tasks"); + + expect(hasDaemonAuthFailure()).toBe(false); + const { result } = renderHook(() => useAuthTokenRecovery()); + expect(result.current.open).toBe(false); + }); + it("removes the daemon auth-failure listener on unmount", () => { const addSpy = vi.spyOn(window, "addEventListener"); const removeSpy = vi.spyOn(window, "removeEventListener"); diff --git a/packages/dashboard/app/hooks/useAuthTokenRecovery.ts b/packages/dashboard/app/hooks/useAuthTokenRecovery.ts index e799face87..8d49ff788a 100644 --- a/packages/dashboard/app/hooks/useAuthTokenRecovery.ts +++ b/packages/dashboard/app/hooks/useAuthTokenRecovery.ts @@ -1,10 +1,10 @@ /* -FNXC:AuthTokenRecovery 2026-06-24-00:00: -App-level open state for the auth-token recovery dialog, opened when the daemon signals auth failure (AUTH_TOKEN_RECOVERY_REQUIRED_EVENT). Extracted verbatim from AppInner. +FNXC:AuthTokenRecovery 2026-07-14-00:00: +App-level open state for the auth-token recovery dialog follows the daemon's auth-failure event and its latch. Cold PWA and bare-URL 401s can fire before React mounts this listener, so the mount-time latch read must open recovery for that missed one-shot event. */ import { useEffect, useState } from "react"; -import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../auth"; +import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, hasDaemonAuthFailure } from "../auth"; export interface UseAuthTokenRecoveryResult { open: boolean; @@ -19,6 +19,10 @@ export function useAuthTokenRecovery(): UseAuthTokenRecoveryResult { }; window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); + if (hasDaemonAuthFailure()) { + setOpen(true); + } + return () => { window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); };