From 19785e40a9df988ea5954b08ed1988f0775c2d83 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 2 Sep 2026 11:47:01 +0100 Subject: [PATCH] Do not treat "have not asked yet" as "not linked" A linked self-hosted instance is still shown the connect prompt, the sidebar "Link Stirling account" button and the feature gates. linkState has three values and none of them is "unknown", so LinkProvider opens at "unlinked" and a separate status call corrects it. useConnectGate reads !isLinked, and its loading flag covers only the app-config query, so in the window between app-config landing and the status arriving a linked instance looks unlinked: the prompt fires and the gates close. The version users actually hit is worse, because it never corrects itself. useAccountLink.refresh swallows a failed status call into { linked: false }. A 401 from a lapsed admin session, a 5xx, a dropped connection, and nothing retries: the instance then reads as unlinked for the rest of the session. So the context now carries whether the status has been read back at all. Anything that blocks waits for it; the gate holds open until then, and stays open if the call never succeeds. A gate that cannot read its own precondition should not be the thing standing in the way, and the real enforcement for these features belongs on the server regardless. The prop defaults to true so a pinned state in a story or test is still believed as given. Only the app passes false, because only the app has to ask. Tests cover the three answers that were being conflated: not yet known, never knowable, and genuinely unlinked, the last so this cannot quietly decay into "never gate". --- .../editor/src/portal/PortalProviders.tsx | 2 +- .../components/LinkAccountFooterItem.tsx | 4 +- .../src/portal/contexts/LinkContext.tsx | 12 ++- .../portal/hooks/connectGateStatus.test.tsx | 87 +++++++++++++++++++ .../editor/src/portal/hooks/useAccountLink.ts | 7 +- .../editor/src/portal/hooks/useConnectGate.ts | 6 +- 6 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 frontend/editor/src/portal/hooks/connectGateStatus.test.tsx 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]);