diff --git a/frontend/editor/src/portal/PortalProviders.tsx b/frontend/editor/src/portal/PortalProviders.tsx index 5008dd8537..6b7749b3c3 100644 --- a/frontend/editor/src/portal/PortalProviders.tsx +++ b/frontend/editor/src/portal/PortalProviders.tsx @@ -30,7 +30,7 @@ function LinkModalHost() { /** Self-hosted provider stack. */ export function PortalProviders() { return ( - + diff --git a/frontend/editor/src/portal/components/LinkAccountFooterItem.tsx b/frontend/editor/src/portal/components/LinkAccountFooterItem.tsx index fd3e9d96a0..0f3d712daf 100644 --- a/frontend/editor/src/portal/components/LinkAccountFooterItem.tsx +++ b/frontend/editor/src/portal/components/LinkAccountFooterItem.tsx @@ -14,8 +14,8 @@ import { LinkIcon } from "@portal/components/icons"; export function LinkAccountFooterItem() { const { t } = useTranslation(); const { openLinkModal } = useUI(); - const { linkState } = useLink(); - if (linkState !== "unlinked") return null; + const { linkState, statusKnown } = useLink(); + if (!statusKnown || linkState !== "unlinked") return null; return ( void; } const LinkContext = createContext(null); @@ -66,11 +69,16 @@ const LinkContext = createContext(null); export function LinkProvider({ children, initialState = "unlinked", + statusKnown: initialStatusKnown = true, }: { children: ReactNode; initialState?: LinkState; + /** Whether `initialState` is authoritative. Only the app passes false; it has to go and ask. */ + statusKnown?: boolean; }) { const [linkState, setLinkState] = useState(initialState); + const [statusKnown, setStatusKnown] = useState(initialStatusKnown); + const markStatusKnown = useCallback(() => setStatusKnown(true), []); const value = useMemo(() => { const unlocked = LINK_INFO[linkState].unlocked; return { @@ -78,8 +86,10 @@ export function LinkProvider({ setLinkState, isLinked: linkState !== "unlinked", featuresUnlocked: unlocked, + statusKnown, + markStatusKnown, }; - }, [linkState]); + }, [linkState, statusKnown, markStatusKnown]); return {children}; } diff --git a/frontend/editor/src/portal/hooks/connectGateStatus.test.tsx b/frontend/editor/src/portal/hooks/connectGateStatus.test.tsx new file mode 100644 index 0000000000..299748fea4 --- /dev/null +++ b/frontend/editor/src/portal/hooks/connectGateStatus.test.tsx @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { LinkProvider } from "@portal/contexts/LinkContext"; +import { UIProvider } from "@portal/contexts/UIContext"; + +/** Over the real provider stack: pins apart not-yet-known, never-knowable and truly unlinked. */ +const { json, fetchStatus } = vi.hoisted(() => ({ + json: vi.fn(), + fetchStatus: vi.fn(), +})); +vi.mock("@portal/api/http", () => ({ + apiClient: { local: { json } }, + errorMessage: String, +})); +vi.mock("@portal/api/link", () => ({ + fetchStatus, + unlinkInstance: vi.fn(), +})); +vi.mock("@portal/auth/saasSupabase", () => ({ + isSaasSupabaseConfigured: false, +})); + +import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +function Probe() { + const { gated, available } = useConnectGate(); + return ( + {`${available ? "avail" : "unavail"}:${gated ? "gated" : "open"}`} + ); +} + +const renderStack = () => + render( + + + + + + + + + , + ); + +const state = () => screen.getByTestId("g").textContent; +const configSaysAvailable = () => + json.mockResolvedValue({ accountLinkAvailable: true }); + +describe("connect gate and the link status", () => { + it("stays open while the status is still in flight", async () => { + configSaysAvailable(); + let resolve!: (v: unknown) => void; + fetchStatus.mockReturnValue(new Promise((r) => (resolve = r))); + renderStack(); + await waitFor(() => expect(state()).toContain("avail")); + expect(state()).toContain("open"); + resolve({ linked: true, name: "acme" }); + }); + + it("stays open once a linked status arrives", async () => { + configSaysAvailable(); + fetchStatus.mockResolvedValue({ linked: true, name: "acme" }); + renderStack(); + await waitFor(() => expect(state()).toContain("avail")); + expect(state()).toContain("open"); + }); + + it("stays open when the status call fails, rather than assuming unlinked", async () => { + configSaysAvailable(); + fetchStatus.mockRejectedValue( + new Error("401 once the admin session lapsed"), + ); + renderStack(); + await waitFor(() => expect(state()).toContain("avail")); + await new Promise((r) => setTimeout(r, 10)); + expect(state()).toContain("open"); + }); + + it("still gates once the status says the instance really is unlinked", async () => { + configSaysAvailable(); + fetchStatus.mockResolvedValue({ linked: false, name: null }); + renderStack(); + await waitFor(() => expect(state()).toBe("avail:gated")); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useAccountLink.ts b/frontend/editor/src/portal/hooks/useAccountLink.ts index 3e47df5220..0ff6584604 100644 --- a/frontend/editor/src/portal/hooks/useAccountLink.ts +++ b/frontend/editor/src/portal/hooks/useAccountLink.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; import { fetchStatus, unlinkInstance, type LinkStatus } from "@portal/api/link"; -import { useApplyLinkFacts } from "@portal/contexts/LinkContext"; +import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; /** Reads and clears THIS instance's link status. */ @@ -22,6 +22,7 @@ export interface UseAccountLink { export function useAccountLink(): UseAccountLink { const applyLinkFacts = useApplyLinkFacts(); + const { markStatusKnown } = useLink(); const [status, setStatus] = useState(null); const [phase, setPhase] = useState("idle"); const [error, setError] = useState(null); @@ -32,10 +33,12 @@ export function useAccountLink(): UseAccountLink { setStatus(s); // A linked instance is at least linked-free; subscription comes from the wallet. if (s.linked) applyLinkFacts(true, false); + // Success only: marking this in the catch would read "could not ask" as "not linked". + markStatusKnown(); } catch { setStatus({ linked: false, name: null }); } - }, [applyLinkFacts]); + }, [applyLinkFacts, markStatusKnown]); useEffect(() => { void refresh(); diff --git a/frontend/editor/src/portal/hooks/useConnectGate.ts b/frontend/editor/src/portal/hooks/useConnectGate.ts index 253ee26212..f0c87d6689 100644 --- a/frontend/editor/src/portal/hooks/useConnectGate.ts +++ b/frontend/editor/src/portal/hooks/useConnectGate.ts @@ -42,8 +42,10 @@ export function useConnectGate(): ConnectGate { }); const available = Boolean(query.data?.accountLinkAvailable) && link != null; - const loading = query.isPending; - const gated = available && !link?.isLinked && !devBypass; + // A status call that never succeeds leaves the gate open rather than blocking on an unknown. + const statusKnown = link?.statusKnown ?? false; + const loading = query.isPending || (link != null && !statusKnown); + const gated = available && statusKnown && !link?.isLinked && !devBypass; const connect = useCallback(() => openLinkModal(), [openLinkModal]);