Do not treat "have not asked yet" as "not linked"

A linked self-hosted instance was still shown the connect prompt, the sidebar
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. The gate read !isLinked
and its loading flag covered only the app-config query, so between app-config
landing and the status arriving a linked instance looked unlinked: the prompt
fired, the gates closed, and markPrompted() burned the once-a-session marker on
a prompt that should never have opened.

Worse, the status call swallows its failures into { linked: false }. A 401 from
a lapsed admin session, a 5xx, a dropped connection, and nothing retries: the
instance then looked unlinked for the rest of the session. That is the version
users hit, because it does not correct itself.

So the context now carries whether the status has actually been read back.
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 go
and ask.

Tests cover the three answers that were being conflated: not yet known, never
knowable, and genuinely unlinked, that last one so this cannot quietly become
"never gate".
This commit is contained in:
Connor Yoh
2026-09-02 11:38:16 +01:00
parent 8ffcbc14c4
commit f5a1c7b235
6 changed files with 145 additions and 9 deletions
@@ -54,7 +54,7 @@ function LinkModalHost() {
*/
export function PortalProviders() {
return (
<LinkProvider initialState="unlinked">
<LinkProvider initialState="unlinked" statusKnown={false}>
<TierProvider>
<UIProvider>
<AccountLinkProvider>
@@ -14,8 +14,9 @@ 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();
// Hidden until the status is actually known, or a linked instance flashes a "link" button.
if (!statusKnown || linkState !== "unlinked") return null;
return (
<NavItem
id="account-link"
@@ -66,6 +66,16 @@ interface LinkContextValue {
*/
saasSessionNonce: number;
markSaasSessionChanged: () => void;
/**
* Whether the instance's link status has actually been read back yet.
*
* `linkState` starts at "unlinked" because the type has no third value, so without this a
* linked instance is indistinguishable from one we have not asked about. Anything that
* BLOCKS on being unlinked has to wait for this; anything that merely reports state need not.
*/
statusKnown: boolean;
/** Called once the status endpoint has actually answered. */
markStatusKnown: () => void;
}
const LinkContext = createContext<LinkContextValue | null>(null);
@@ -73,11 +83,19 @@ const LinkContext = createContext<LinkContextValue | null>(null);
export function LinkProvider({
children,
initialState = "unlinked",
statusKnown: initialStatusKnown = true,
}: {
children: ReactNode;
initialState?: LinkState;
/**
* Whether `initialState` is authoritative. Defaults to true so a pinned state (stories, tests)
* is believed as given; the app passes false because it has to go and ask first.
*/
statusKnown?: boolean;
}) {
const [linkState, setLinkState] = useState<LinkState>(initialState);
const [statusKnown, setStatusKnown] = useState(initialStatusKnown);
const markStatusKnown = useCallback(() => setStatusKnown(true), []);
const [saasSessionNonce, setSaasSessionNonce] = useState(0);
const markSaasSessionChanged = useCallback(
() => setSaasSessionNonce((n) => n + 1),
@@ -92,8 +110,16 @@ export function LinkProvider({
featuresUnlocked: unlocked,
saasSessionNonce,
markSaasSessionChanged,
statusKnown,
markStatusKnown,
};
}, [linkState, saasSessionNonce, markSaasSessionChanged]);
}, [
linkState,
saasSessionNonce,
markSaasSessionChanged,
statusKnown,
markStatusKnown,
]);
return <LinkContext.Provider value={value}>{children}</LinkContext.Provider>;
}
@@ -0,0 +1,101 @@
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";
/**
* The gate over the real provider stack, which is where it went wrong in the field: a linked
* instance was still shown the connect prompt and the sidebar button.
*
* `linkState` starts at "unlinked" because the type has no third value, and the status that
* corrects it arrives separately from the app config. Gating on `!isLinked` alone therefore reads
* "we have not asked yet" as "not linked". These pin the three answers apart: not yet known,
* never knowable, and actually not linked.
*/
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,
linkInstance: vi.fn(),
unlinkInstance: vi.fn(),
}));
vi.mock("@portal/auth/saasSupabase", () => ({
PENDING_LINK_KEY: "k",
isSaasSupabaseConfigured: false,
SAAS_OAUTH_PROVIDERS: [],
ensureSaasSupabase: () => null,
}));
import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext";
import { useConnectGate } from "@portal/hooks/useConnectGate";
function Probe() {
const { gated, available } = useConnectGate();
return (
<span data-testid="g">
{`${available ? "avail" : "unavail"}:${gated ? "gated" : "open"}`}
</span>
);
}
const renderStack = () =>
render(
<PortalTestProviders>
<LinkProvider initialState="unlinked" statusKnown={false}>
<UIProvider>
<AccountLinkProvider>
<Probe />
</AccountLinkProvider>
</UIProvider>
</LinkProvider>
</PortalTestProviders>,
);
const state = () => screen.getByTestId("g").textContent;
const availableConfig = () =>
json.mockResolvedValue({ accountLinkAvailable: true });
describe("connect gate and the link status", () => {
it("stays open while the status is still in flight", async () => {
availableConfig();
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 () => {
availableConfig();
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 () => {
availableConfig();
fetchStatus.mockRejectedValue(
new Error("401 after 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 () => {
availableConfig();
fetchStatus.mockResolvedValue({ linked: false, name: null });
renderStack();
await waitFor(() => expect(state()).toBe("avail:gated"));
});
});
@@ -46,7 +46,7 @@ export interface UseAccountLink {
export function useAccountLink(): UseAccountLink {
const applyLinkFacts = useApplyLinkFacts();
const { markSaasSessionChanged } = useLink();
const { markSaasSessionChanged, markStatusKnown } = useLink();
const [status, setStatus] = useState<LinkStatus | null>(null);
const [phase, setPhase] = useState<LinkPhase>("idle");
const [error, setError] = useState<string | null>(null);
@@ -80,6 +80,9 @@ 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);
// Only a real answer counts. Marking this on failure too would put us back to
// treating "could not ask" as "not linked", which is what gated linked instances.
markStatusKnown();
}
})
.catch(() => {
@@ -91,7 +94,7 @@ export function useAccountLink(): UseAccountLink {
return () => {
cancelled = true;
};
}, [applyLinkFacts]);
}, [applyLinkFacts, markStatusKnown]);
// SSO return: an SSO sign-in we kicked off has redirected back and the SaaS
// session is now in the shared Supabase client. The pending marker carries the
@@ -40,7 +40,7 @@ export interface ConnectGate {
* extra request.
*/
export function useConnectGate(): ConnectGate {
const { isLinked } = useLink();
const { isLinked, statusKnown } = useLink();
const { openLinkModal } = useUI();
const devBypass = useDevConnectBypass();
@@ -51,10 +51,15 @@ export function useConnectGate(): ConnectGate {
});
const available = Boolean(query.data?.accountLinkAvailable);
const loading = query.isPending;
// Both answers have to be in. The link status arrives separately from the app config and
// starts out as "unlinked" simply because the type has no third value, so gating before it
// lands blocks and nags an instance that is in fact linked. If the status call fails we never
// learn the answer and stay open: a gate that cannot read its own precondition should not be
// the thing standing in the way.
const loading = query.isPending || !statusKnown;
// Dev-only, and absent from every build. See useDevConnectBypass for why this cannot be a
// setting: the gate is currently the only thing enforcing that these features need a link.
const gated = available && !isLinked && !devBypass;
const gated = available && statusKnown && !isLinked && !devBypass;
const connect = useCallback(() => openLinkModal(), [openLinkModal]);