mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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".
This commit is contained in:
@@ -30,7 +30,7 @@ function LinkModalHost() {
|
||||
/** Self-hosted provider stack. */
|
||||
export function PortalProviders() {
|
||||
return (
|
||||
<LinkProvider initialState="unlinked">
|
||||
<LinkProvider initialState="unlinked" statusKnown={false}>
|
||||
<TierProvider>
|
||||
<UIProvider>
|
||||
<AccountLinkProvider>
|
||||
|
||||
@@ -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 (
|
||||
<NavItem
|
||||
id="account-link"
|
||||
|
||||
@@ -59,6 +59,9 @@ interface LinkContextValue {
|
||||
isLinked: boolean;
|
||||
/** Convenience for `LINK_INFO[linkState].unlocked` — billable features usable. */
|
||||
featuresUnlocked: boolean;
|
||||
/** `linkState` has no "unknown", so without this "not asked yet" reads as "not linked". */
|
||||
statusKnown: boolean;
|
||||
markStatusKnown: () => void;
|
||||
}
|
||||
|
||||
const LinkContext = createContext<LinkContextValue | null>(null);
|
||||
@@ -66,11 +69,16 @@ const LinkContext = createContext<LinkContextValue | null>(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<LinkState>(initialState);
|
||||
const [statusKnown, setStatusKnown] = useState(initialStatusKnown);
|
||||
const markStatusKnown = useCallback(() => setStatusKnown(true), []);
|
||||
const value = useMemo<LinkContextValue>(() => {
|
||||
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 <LinkContext.Provider value={value}>{children}</LinkContext.Provider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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 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"));
|
||||
});
|
||||
});
|
||||
@@ -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<LinkStatus | null>(null);
|
||||
const [phase, setPhase] = useState<LinkPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user